diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba334c3c..a5ea8ecd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,21 +65,28 @@ jobs: env: CODESIGN_CERTIFICATE_BASE64: ${{ secrets.CODESIGN_CERTIFICATE_BASE64 }} CODESIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODESIGN_CERTIFICATE_PASSWORD }} + KEYCHAIN_SESSION_STATE_DIR: ${{ runner.temp }}/keychain-session run: | + set -euo pipefail CERT_PATH="$RUNNER_TEMP/codesign.p12" - KEYCHAIN_PATH="$RUNNER_TEMP/codesign.keychain-db" echo "$CODESIGN_CERTIFICATE_BASE64" | base64 -D > "$CERT_PATH" - security create-keychain -p "" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "" "$KEYCHAIN_PATH" + + # The search-list / default-keychain dance is NOT inlined here any + # more. This step used to prepend an ephemeral keychain to the user + # search list and never put the list back — it relied on + # `delete-keychain` unlisting it, which only works while the file + # still exists. On a self-hosted runner that leaves the operator's + # own keychain domain pointing at a deleted path (2026-08-15 P0). + # scripts/lib/keychain-session.sh snapshots as structured argv, + # unlists before deleting, and is concurrency-safe. + KEYCHAIN_PATH="$(scripts/lib/keychain-session.sh begin codescribe-signing)" + echo "CODESCRIBE_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + # Read into a variable, never echo. The ephemeral password exists so + # the keychain is not world-openable on a shared runner. + KEYCHAIN_PASSWORD="$(cat "$(scripts/lib/keychain-session.sh password-file codescribe-signing)")" security import "$CERT_PATH" -P "$CODESIGN_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - existing_keychains=() - while IFS= read -r keychain; do - keychain="${keychain//\"/}" - [[ -n "$keychain" ]] && existing_keychains+=("$keychain") - done < <(security list-keychains -d user) - security list-keychains -d user -s "$KEYCHAIN_PATH" "${existing_keychains[@]}" - security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null security find-identity -v -p codesigning "$KEYCHAIN_PATH" # The identity now lives in the keychain; the raw PKCS12 has no # further use. Overwrite-then-unlink so the private key material @@ -187,12 +194,24 @@ jobs: appcast.xml generate_release_notes: true - - name: Remove signing keychain + - name: Release the signing keychain # Runs on every outcome: a failed build must not leave the imported - # Developer ID identity resident on the runner. + # Developer ID identity resident on the runner, AND must not leave the + # ephemeral keychain in the user search list or as the default + # keychain. `end` unlists first and deletes second, so it still cleans + # up when the keychain file has already been destroyed — the exact + # sequence that poisoned the operator's host on 2026-08-15. if: always() + env: + KEYCHAIN_SESSION_STATE_DIR: ${{ runner.temp }}/keychain-session run: | - KEYCHAIN_PATH="$RUNNER_TEMP/codesign.keychain-db" - if [ -f "$KEYCHAIN_PATH" ]; then - security delete-keychain "$KEYCHAIN_PATH" + if [ ! -x scripts/lib/keychain-session.sh ]; then + echo "::warning::checkout missing; cannot release the signing keychain" + exit 0 fi + scripts/lib/keychain-session.sh end codescribe-signing + # Read-only verdict on what the runner's keychain domain looks like + # now. Advisory: a dirty domain must be visible, not silent, but it + # must not fail an otherwise successful release. + scripts/keychain-doctor.sh || \ + echo "::warning::runner keychain domain still has stale entries after cleanup" diff --git a/Cargo.toml b/Cargo.toml index 3749a2e0..83630eda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,10 @@ path = "bin/codescribe.rs" name = "codescribe-teacher" path = "bin/codescribe-teacher.rs" +[[bin]] +name = "codescribe-corpus" +path = "bin/codescribe-corpus.rs" + [lib] path = "app/lib.rs" @@ -107,6 +111,7 @@ chrono = "0.4" # UUID generation uuid = { version = "1", features = ["v4"] } +sha2 = "0.10" # Lazy static for global state lazy_static = "1.4" @@ -136,7 +141,6 @@ tempfile = "3" mockito = "1" serial_test = "3" hound = "3.5" -sha2 = "0.10" [lints.rust] # Allow unexpected_cfgs from objc crate's msg_send! macro (uses cargo-clippy cfg internally) diff --git a/Makefile b/Makefile index f9e1fb2a..1fcfcf55 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ bump bump-patch bump-minor bump-major version \ lint format test test-quick test-e2e test-e2e-real test-sse test-sse-release test-responses-live test-sse-heavy test-formatting test-all \ test-engine test-engine-apple test-engine-candle test-teacher \ - demo demo-raw demo-assistive check verify semgrep fix clean help \ + demo demo-raw demo-assistive check verify semgrep fix clean help corpus-census test-corpus-parity \ dist-preflight dist-preflight-signed verify-canaries smoke-canaries \ dmg dmg-signed release-standard release-full release-dmgs notarize verify-dmg download-model download-e5 download-embedder ensure-models \ hooks @@ -77,6 +77,18 @@ LOCAL_LLM_ENDPOINT ?= http://localhost:11434/v1/responses LOCAL_LLM_MODEL ?= gpt-oss:120b-cloud LOCAL_LLM_API_KEY ?= local +# Content-private corpus inventory and production-session replay. The binary +# hard-disables Keychain and never loads operator settings/.env. Machine reports +# are content-redacted; private mode-0600 Qube HTML carries review transcripts. +CORPUS_ROOTS ?= $(HOME)/.codescribe/data_assets $(HOME)/.codescribe/transcriptions +CORPUS_REFERENCE_POLICY ?= human +CORPUS_PROFILES ?= apple-layer0,apple-layer1-inprocess +CORPUS_RUNS ?= 1 +CORPUS_MAX_RECORDINGS ?= 1 +CORPUS_APPLE_BRIDGE ?= /Applications/Codescribe.app/Contents/MacOS/codescribe-stt-bridge +CORPUS_RUN_ID ?= $(shell date +%Y%m%d-%H%M%S) +CORPUS_OUT ?= $(HOME)/.vibecrafted/artifacts/vetcoders/codescribe/$(shell date +%Y_%m%d)/reports/corpus-$(CORPUS_RUN_ID) + define APPLY_TEST_LLM if [[ "$(TEST_USE_LOCAL_LLM)" == "1" ]]; then \ export LLM_ENDPOINT="$(LOCAL_LLM_ENDPOINT)"; \ @@ -102,22 +114,23 @@ build: @echo "Building (debug)..." @cargo build -# Slim public default: Silero VAD + MiniLM. Whisper is runtime/cache/Settings download. -# Do NOT set CODESCRIBE_EMBED_WHISPER here — that is the fat experimental SKU only. +# Slim public default: Silero in the dylib; MiniLM is a signed app resource; +# Whisper is runtime/cache/Settings download. Large model bytes never flow +# through normal Cargo targets. release-codescribe: dist-preflight - @echo "Building codescribe-ffi (release dylib, embedded: Silero + MiniLM; Whisper runtime)..." + @echo "Building codescribe-ffi (release dylib: Silero embedded; MiniLM/Whisper runtime)..." @echo " The app front-end is no longer a Rust bin; this builds the UniFFI bridge dylib." @echo " Produce the runnable SwiftUI app with: make app PROFILE=release" @echo " Fat Whisper embed: make release-codescribe-embedded" @CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ - env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_NO_EMBED cargo build --release -p codescribe-ffi + env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_EMBED_EMBEDDER -u CODESCRIBE_NO_EMBED cargo build --release -p codescribe-ffi # Optional fat SKU / offline curiosity: bake Whisper into the dylib (~1GB+). # Not the daily release path. Pair with `make release-full` for a _full DMG. release-codescribe-embedded: dist-preflight ensure-models - @echo "Building codescribe-ffi (FAT: Silero + MiniLM + Whisper embedded)..." + @echo "Building codescribe-ffi (FAT Whisper: Silero + Whisper embedded; MiniLM runtime resource)..." @CODESCRIBE_EMBED_WHISPER=1 CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ - cargo build --release -p codescribe-ffi + env -u CODESCRIBE_EMBED_EMBEDDER cargo build --release -p codescribe-ffi # ── SwiftUI app (macos/) via the codescribe-ffi UniFFI bridge ──────────────── # Full verified pipeline: cargo (ffi dylib) → uniffi-bindgen → xcodegen → xcodebuild. @@ -138,10 +151,10 @@ release-qube: dist-preflight release: release-codescribe release-qube install: - @echo "Installing qube tools + codescribe CLI (slim: Silero + MiniLM; Whisper from cache / Settings)..." + @echo "Installing qube tools + codescribe CLI (Silero embedded; MiniLM/Whisper from cache)..." @echo "Local install uses the development license verifier — same contract as install-app." @./scripts/download-embedder.sh || true - @env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_NO_EMBED -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX \ + @env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_EMBED_EMBEDDER -u CODESCRIBE_NO_EMBED -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX \ CODESCRIBE_LOCAL_INSTALL=1 cargo install --path . --force @mkdir -p ~/.codescribe @$(MAKE) hooks @@ -298,7 +311,8 @@ bump-major: # gate: verify class=hermetic ci=yes -- the workspace test set + doctests + env registry + this ledger; the command rust.yml runs # gate: verify-canaries class=hermetic ci=no -- claim-vs-execution canaries that read repo files only (scripts/canaries.sh); each row is born from a named incident # gate: verify-swift-format class=static ci=no -- swift-format lint --strict over macos/Codescribe + macos/CodescribeTests; skips the generated UniFFI binding; no Swift tests (that is test-swift) -# gate: smoke-canaries class=operator ci=no -- verify-canaries + host rows: dist inputs, appcast feed, live-store purity, Sparkle key parity (scripts/canaries.sh --host) +# gate: smoke-canaries class=operator ci=no -- verify-canaries + host rows: dist inputs, appcast feed, live-store purity, Sparkle key parity, keychain domain cleanliness (scripts/canaries.sh --host) +# gate: test-keychain-session class=hermetic ci=no -- ephemeral signing-keychain contract (scripts/tests/keychain-session-test.sh) against a FAKE security binary and a temp HOME; touches no real keychain # gate: verify-dmg class=operator ci=no -- fail-closed payload check against an already-built DMG; release.yml runs the same check via scripts/verify-dmg-payload.sh, not via this target # gate: test class=operator ci=no -- workspace tests + #[ignore] real-API tests + STT pipeline; sources ~/.codescribe/.env and opens Console # gate: test-quick class=operator ci=no -- workspace tests only, but still sources ~/.codescribe/.env and opens Console @@ -317,6 +331,7 @@ bump-major: # gate: test-engine-parity class=operator ci=no -- Layer 0 parity bar vs the Apple reference; private corpus, host-local bench # gate: test-engine-parity-layered class=operator ci=no -- Layer 1 parity arm judged on structure; private corpus, host-local bench # gate: test-engine-parity-both class=operator ci=no -- runs both parity arms and prints the delta +# gate: test-corpus-parity class=operator ci=no -- isolated production-session file replay; private corpus and local STT models # gate: test-teacher class=operator ci=no -- teacher CLI proof run, writes an HTML report # gate: test-swift class=operator ci=no -- SwiftUI suite + Apple phrase-restart Rust/Swift lockstep self-test; needs Xcode and built ffi/bridge binaries # gate: smoke-macos27 class=operator ci=no -- host smoke after an OS/Xcode bump; operator-only rows report SKIP @@ -763,6 +778,47 @@ test-engine-parity-both: fi; \ [ "$$off_rc" -eq 0 ] && [ "$$on_rc" -eq 0 ] +# Inventory every configured corpus root without loading operator settings, +# dotenv or Keychain. Census always discovers historical same-stem references; +# replay decides separately whether they are admissible as quality references. +.PHONY: corpus-census +corpus-census: + @set -euo pipefail; \ + root_args=(); \ + for root in $(CORPUS_ROOTS); do root_args+=(--root "$$root"); done; \ + CODESCRIBE_DISABLE_KEYCHAIN=1 cargo run --quiet --bin codescribe-corpus -- census \ + "$${root_args[@]}" \ + --include-historical \ + --out "$(CORPUS_OUT)/census.json" + +# Production PCM-session replay. One recording x the two core arms is the safe +# default; expand CORPUS_PROFILES / CORPUS_MAX_RECORDINGS deliberately for a +# retained matrix. Each profile runs in a fresh process and isolated data root. +.PHONY: test-corpus-parity +test-corpus-parity: + @set -euo pipefail; \ + root_args=(); \ + max_args=(); \ + source_identity="$$(git rev-parse HEAD)"; \ + if [ -n "$$(git status --porcelain --untracked-files=all)" ]; then source_identity="$$source_identity-dirty"; fi; \ + if [ ! -x "$(CORPUS_APPLE_BRIDGE)" ]; then \ + printf 'corpus parity refused: signed Apple STT bridge is not executable: %s\n' "$(CORPUS_APPLE_BRIDGE)" >&2; \ + exit 2; \ + fi; \ + for root in $(CORPUS_ROOTS); do root_args+=(--root "$$root"); done; \ + if [ -n "$(strip $(CORPUS_MAX_RECORDINGS))" ]; then \ + max_args+=(--max-recordings "$(CORPUS_MAX_RECORDINGS)"); \ + fi; \ + CODESCRIBE_DISABLE_KEYCHAIN=1 cargo run --quiet --bin codescribe-corpus -- run \ + "$${root_args[@]}" \ + "$${max_args[@]}" \ + --out-dir "$(CORPUS_OUT)" \ + --profiles "$(CORPUS_PROFILES)" \ + --runs "$(CORPUS_RUNS)" \ + --references "$(CORPUS_REFERENCE_POLICY)" \ + --apple-bridge "$(CORPUS_APPLE_BRIDGE)" \ + --commit "$$source_identity" + # Host smoke for the macOS surfaces we own — run after every OS/Xcode bump. # Headless, raises no TCC dialog, posts no synthetic events; operator-only rows # report SKIP instead of passing quietly. SMOKE_ARGS='--with-inference' adds the @@ -1055,6 +1111,15 @@ verify-canaries: smoke-canaries: @bash scripts/canaries.sh --host +# The signing keychain borrows the operator's user keychain domain. This proves +# it always gives it back — on success, on failure, on Ctrl-C, under concurrent +# releases, and when the keychain file was destroyed before cleanup ran (the +# 2026-08-15 P0). Hermetic: `security` is a fake binary in a temp dir and HOME +# is redirected, so running it on this host cannot touch a real keychain. +.PHONY: test-keychain-session +test-keychain-session: + @bash scripts/tests/keychain-session-test.sh + .PHONY: canary-catalog canary-catalog: @bash scripts/canaries.sh --list @@ -1103,7 +1168,7 @@ help: @printf '\n' @printf ' $(HELP_C_YELLOW)%s$(HELP_C_RESET)\n' 'BUILD & INSTALL' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'build' 'Build debug binary' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release' 'Build release dylib slim (Silero + MiniLM; Whisper runtime)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release' 'Build release dylib slim (Silero embedded; MiniLM/Whisper runtime)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install' 'Install CLI slim (Whisper via cache/Settings, not embedded)' @printf '%s\n' ' make install-no-embed DEV/RECOVERY: no optional embeds (runtime paths only)' @printf '%s\n' ' make release-codescribe-embedded Fat dylib with Whisper baked in (not daily)' @@ -1160,6 +1225,8 @@ help: @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-engine-apple' 'Apple live multi-utterance e2e (ENGINE_CLIP / ENGINE_ALL_CLIPS=1)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-engine-candle' 'Candle live multi-utterance e2e (same engine bar)' @printf '%s\n' ' make test-engine-parity-both Both parity arms + delta (needs the private corpus)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'corpus-census' 'Inventory both private corpus roots; hashes/counts only' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-corpus-parity' 'Isolated production replay (profiles/runs/recordings are explicit vars)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-teacher' 'Teacher CLI proof HTML (live×whisper×human)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-all' 'Run full test suite' @@ -1206,7 +1273,8 @@ dist-preflight-signed: dist-preflight fi @echo "dist preflight: Sparkle public key OK (32-byte Ed25519 from $(if $(SPARKLE_ED_PUBLIC_KEY),environment,$(CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE)))" -# Daily slim DMG (public default): Silero + MiniLM, Whisper NOT embedded. +# Daily slim DMG (public default): Silero embedded, MiniLM runtime resource, +# Whisper NOT embedded. dmg: dist-preflight @CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" ./scripts/build-dmg.sh @@ -1235,7 +1303,8 @@ release-standard: dist-preflight-signed ./scripts/verify-dmg-payload.sh "$$DMG" --variant slim --version "$$VERSION" # Optional fat SKU: bake Whisper (~1GB+) into the app. Not the daily path. -# Ends with the fail-closed payload gate (full = Silero + MiniLM + Whisper). +# Ends with the fail-closed payload gate (full = Silero + Whisper embedded, +# MiniLM runtime resource). release-full: dist-preflight-signed ensure-models @CODESCRIBE_CODESIGN_IDENTITY="$(CODESCRIBE_DIST_CODESIGN_IDENTITY)" \ CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ diff --git a/README.md b/README.md index 8c07383a..5d54053c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Codescribe can load custom MCP servers from `~/.codescribe/mcp.json`. That keeps ## Features - **Rust core + SwiftUI app** — Native macOS SwiftUI shell over the Rust engine through UniFFI, with candle-core + Metal GPU -- **Two DMG variants** — Standard (daily) embeds Silero VAD + MiniLM; Whisper is downloaded from Settings → Dictation or HF cache. Optional `_full` DMG also embeds Whisper for offline/curiosity installs. +- **Two DMG variants** — Standard (daily) embeds Silero VAD and signs MiniLM as a runtime app resource; Whisper is downloaded from Settings → Dictation or HF cache. Optional `_full` DMG also embeds Whisper for offline/curiosity installs. - **Whisper Live** — Streaming transcription happens _during recording_ (chunks + overlap), so `stop()` is near-instant - **Stream postprocess** — semantic gating + cleanup of live chunks before final output @@ -334,7 +334,7 @@ Codescribe uses **whisper-large-v3-turbo** (mlx-community, fp16): ### Runtime Whisper (Current) -**Daily public builds are slim.** `make release`, `make dmg` / `dmg-signed`, and `make release-standard` embed **Silero VAD** (required) and **MiniLM** when available. **Whisper is not baked in** (~900 MB–1.5 GB saved). Install local Candle Whisper from **Settings → Dictation → Download Whisper**, or run `make download-model`. +**Daily public builds keep large weights out of Cargo artifacts.** `make release`, `make dmg` / `dmg-signed`, and `make release-standard` embed only **Silero VAD** in the Rust engine. **MiniLM** is copied into the signed app as a runtime resource, while **Whisper is not baked in** (~900 MB–1.5 GB saved). Install local Candle Whisper from **Settings → Dictation → Download Whisper**, or run `make download-model`. Optional fat SKU (offline / curiosity): `make release-full` or `CODESCRIBE_EMBED_WHISPER=1` / `make release-codescribe-embedded`. @@ -350,7 +350,7 @@ The mlx-community repo ships only `config.json` + `weights.safetensors`; the download paths compose `tokenizer.json` + `mel_filters.npz` from the legacy repo (both files are quantization-independent). -`CODESCRIBE_NO_EMBED=1` is a development/recovery path that also skips MiniLM embed; it is not the public slim product path. +`CODESCRIBE_EMBED_EMBEDDER=1` is an explicit fat/debug path that compiles MiniLM into Rust artifacts. Normal builds resolve MiniLM from the signed app resource or HF cache. `CODESCRIBE_NO_EMBED=1` disables every optional binary embed; Silero remains embedded. Model files required: diff --git a/app/controller/helpers.rs b/app/controller/helpers.rs index 06c21b1b..588606f6 100644 --- a/app/controller/helpers.rs +++ b/app/controller/helpers.rs @@ -1161,6 +1161,36 @@ fn legacy_fallback_assistant_text( } } +const LEGACY_FALLBACK_UNAVAILABLE_MESSAGE: &str = + "Agent provider and fallback are unavailable. Check Settings → Providers."; + +/// Build the one terminal sequence the Swift Agent surface must receive after +/// the runtime has handed a voice turn to the legacy formatter. The fallback +/// used to persist its result without publishing anything, leaving the +/// assistant placeholder permanently in `Thinking` after a fast provider/key +/// failure. Keep this mapping pure so the terminal contract is testable without +/// a live provider or a Swift listener. +fn legacy_fallback_terminal_events(assistant_text: Option<&str>) -> Vec { + match assistant_text + .map(str::trim) + .filter(|text| !text.is_empty()) + { + Some(text) => vec![ + AgentDeliveryEvent::TextDone(text.to_string()), + AgentDeliveryEvent::Done, + ], + None => vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string(), + )], + } +} + +fn publish_legacy_fallback_terminal(assistant_text: Option<&str>) { + for event in legacy_fallback_terminal_events(assistant_text) { + crate::agent_delivery::publish_agent_delivery_event(event); + } +} + /// Answer one turn through the legacy formatter instead of the agent runtime. /// Returns the assistant text only when there is genuine output to persist; a /// formatter failure logs and yields `None` rather than writing a junk thread. @@ -1236,18 +1266,25 @@ async fn run_agent_send_with_fallback( ); debug!("Legacy fallback input length: {}", text.len()); let fallback_assistant_text = run_legacy_send_path(&text, whisper_language).await; - if let Some(assistant_text) = fallback_assistant_text { - match deliver_legacy_assistive_thread(&text, &assistant_text) { - Ok(Some(receipt)) => debug!( - backend_thread_id = %receipt.backend_id, - message_count = receipt.message_count, - "Legacy assistive fallback delivered" - ), - Ok(None) => {} - Err(error) => { - warn!("Failed to deliver legacy assistive fallback thread: {error}") + match fallback_assistant_text { + Some(assistant_text) => { + match deliver_legacy_assistive_thread(&text, &assistant_text) { + Ok(Some(receipt)) => { + debug!( + backend_thread_id = %receipt.backend_id, + message_count = receipt.message_count, + "Legacy assistive fallback delivered" + ); + publish_legacy_fallback_terminal(Some(&assistant_text)); + } + Ok(None) => publish_legacy_fallback_terminal(None), + Err(error) => { + warn!("Failed to deliver legacy assistive fallback thread: {error}"); + publish_legacy_fallback_terminal(None); + } } } + None => publish_legacy_fallback_terminal(None), } } } @@ -1756,6 +1793,32 @@ mod tests { ); } + /// A formatter fallback is still the terminal owner of the already-open + /// voice bubble. Success must fill and close it; failure must close it with + /// an actionable error. Neither branch may leave Swift in `Thinking`. + #[test] + fn legacy_fallback_always_publishes_a_terminal_ui_sequence() { + assert_eq!( + legacy_fallback_terminal_events(Some(" recovered reply ")), + vec![ + AgentDeliveryEvent::TextDone("recovered reply".to_string()), + AgentDeliveryEvent::Done, + ] + ); + assert_eq!( + legacy_fallback_terminal_events(None), + vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string() + )] + ); + assert_eq!( + legacy_fallback_terminal_events(Some(" ")), + vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string() + )] + ); + } + /// Provider that never emits anything: its event channel is closed /// immediately. Used where a session must exist but must not produce /// conversation history of its own. diff --git a/bin/codescribe-corpus.rs b/bin/codescribe-corpus.rs new file mode 100644 index 00000000..5a7b841a --- /dev/null +++ b/bin/codescribe-corpus.rs @@ -0,0 +1,2013 @@ +//! Unattended corpus census and production-overlay replay. +//! +//! This tool never loads the operator's `settings.json` or `.env`. Matrix +//! profiles run in fresh child processes with an isolated +//! `CODESCRIBE_DATA_DIR`; audio is the only substituted production boundary. +//! Machine reports contain hashes, counts and scores, never filenames or +//! transcript bodies. A separate private Qube HTML contains transcript bodies +//! and opaque audio links for local operator review. + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fmt::Write as _; +use std::fs::{self, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, ExitCode, Stdio}; +use std::str::FromStr; +use std::time::Instant; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::Utc; +use clap::{Parser, Subcommand, ValueEnum}; +use codescribe::controller::production_replay::{ProductionReplayLane, replay_overlay_recording}; +use codescribe::qube_report::{ + LocalTranscriptionMode, MetricsReference, QualityReport, QualityReportConfig, ReportEntry, + ReportEnvironment, ReportMetrics, ReportSummary, ReportTranscriptSemantics, + ReportTranscriptState, ReportTranscripts, render_html as render_qube_html, +}; +use codescribe_core::asr_session::GatewaySessionAvailability; +use codescribe_core::config::UserSettings; +use codescribe_core::pipeline::contracts::{EngineEvent, LayerSource}; +use codescribe_core::quality::engine_contract::{CORPUS_REPORT_SCHEMA, ENGINE_CONTRACT_ID}; +use codescribe_core::util::safe_path::{safe_open, safe_symlink_or_copy_bounded}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const REPORT_SCHEMA: &str = CORPUS_REPORT_SCHEMA; +const AUDIO_EXTENSIONS: [&str; 3] = ["wav", "m4a", "mp3"]; +const CONTROLLED_ENV: [&str; 14] = [ + "CODESCRIBE_STT_ENGINE", + "CODESCRIBE_LAYERED_TRANSCRIPTION", + "STT_TAIL_PROVIDER", + "CODESCRIBE_SILERO_FUSION", + "CODESCRIBE_SILERO_FUSION_CONTEXT", + "CODESCRIBE_SPAN_IDEMPOTENCE", + "CODESCRIBE_INLINE_FORMAT", + "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", + "FINAL_PASS_MODE", + "CODESCRIBE_FINAL_PASS_MODE", + "CODESCRIBE_LOCAL_STT_FINAL_PASS", + "CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", + "CODESCRIBE_APPLE_STT_BRIDGE", + "CODESCRIBE_BRIDGE_DISCLAIM", +]; + +#[derive(Debug, Parser)] +#[command( + name = "codescribe-corpus", + about = "Private corpus census and production-overlay replay", + version +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Inventory audio and reference classes without running STT. + Census { + /// Corpus root. Repeat to combine roots. + #[arg(long = "root", required = true)] + roots: Vec, + /// JSON report path. + #[arg(long)] + out: PathBuf, + /// Treat same-stem TXT files as historical references. + #[arg(long)] + include_historical: bool, + }, + /// Run one or more isolated replay profiles and write a retained report. + Run { + /// Corpus root. Repeat to combine roots. + #[arg(long = "root", required = true)] + roots: Vec, + /// Durable report directory. + #[arg(long)] + out_dir: PathBuf, + /// Comma-separated profile names. + #[arg( + long, + value_delimiter = ',', + default_value = "apple-layer0,apple-layer1-inprocess" + )] + profiles: Vec, + /// Independent executions per distinct recording and profile. + #[arg(long, default_value_t = 1)] + runs: usize, + /// Reference selection policy. + #[arg(long, value_enum, default_value_t = ReferencePolicy::Human)] + references: ReferencePolicy, + /// Bound the selected distinct recordings after stable hash sort. + #[arg(long)] + max_recordings: Option, + /// Recognition language pin. + #[arg(long, default_value = "pl")] + language: String, + /// Exact source commit claimed by this binary invocation. + #[arg(long)] + commit: String, + /// Exact signed Apple STT bridge artifact used by every worker. + #[arg(long)] + apple_bridge: PathBuf, + }, + /// Internal one-profile worker. Fresh process = fresh runtime globals. + #[command(hide = true)] + Worker { + #[arg(long = "root", required = true)] + roots: Vec, + #[arg(long)] + out: PathBuf, + #[arg(long)] + profile: ReplayProfile, + #[arg(long, default_value_t = 1)] + runs: usize, + #[arg(long, value_enum)] + references: ReferencePolicy, + #[arg(long)] + max_recordings: Option, + #[arg(long)] + language: String, + #[arg(long)] + commit: String, + #[arg(long)] + apple_bridge: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum ReferencePolicy { + /// Only explicit `_human_transcription.txt` siblings are quality truth. + Human, + /// Prefer explicit human truth, then admit same-stem historical TXT. + HumanAndHistorical, +} + +impl ReferencePolicy { + const fn as_str(self) -> &'static str { + match self { + Self::Human => "human", + Self::HumanAndHistorical => "human_and_historical", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum ReplayProfile { + /// Apple canvas + lexicon, Layer 1 disarmed. + AppleLayer0, + /// Apple canvas + in-process Whisper partials + lexicon. + AppleLayer1Inprocess, + /// Apple canvas + sidecar Whisper partials + lexicon. + AppleLayer1Sidecar, + /// Apple canvas + remote Whisper partials + lexicon. + AppleLayer1Remote, + /// Layer 1 plus Silero utterance identity, utterance-only context. + AppleLayer1FusionUtterance, + /// Layer 1 plus Silero identity and bounded left-audio context. + AppleLayer1FusionLeftPad, + /// Layer 1 plus Silero identity and stable-text prompt context. + AppleLayer1FusionStablePrompt, + /// Fusion plus sealed-span replay/idempotence fence. + AppleLayer1FusionIdempotent, + /// Layer 1 live transcript plus the opt-in local final-pass stop lane. + AppleLayer1LocalFinalPass, +} + +impl ReplayProfile { + const fn token(self) -> &'static str { + match self { + Self::AppleLayer0 => "apple-layer0", + Self::AppleLayer1Inprocess => "apple-layer1-inprocess", + Self::AppleLayer1Sidecar => "apple-layer1-sidecar", + Self::AppleLayer1Remote => "apple-layer1-remote", + Self::AppleLayer1FusionUtterance => "apple-layer1-fusion-utterance", + Self::AppleLayer1FusionLeftPad => "apple-layer1-fusion-left-pad", + Self::AppleLayer1FusionStablePrompt => "apple-layer1-fusion-stable-prompt", + Self::AppleLayer1FusionIdempotent => "apple-layer1-fusion-idempotent", + Self::AppleLayer1LocalFinalPass => "apple-layer1-local-final-pass", + } + } + + const fn layered(self) -> bool { + !matches!(self, Self::AppleLayer0) + } + + const fn tail_provider(self) -> &'static str { + match self { + Self::AppleLayer1Sidecar => "sidecar", + Self::AppleLayer1Remote => "remote", + _ => "inprocess", + } + } + + const fn fusion(self) -> bool { + matches!( + self, + Self::AppleLayer1FusionUtterance + | Self::AppleLayer1FusionLeftPad + | Self::AppleLayer1FusionStablePrompt + | Self::AppleLayer1FusionIdempotent + ) + } + + const fn fusion_context(self) -> &'static str { + match self { + Self::AppleLayer1FusionLeftPad => "left_pad", + Self::AppleLayer1FusionStablePrompt => "stable_prompt", + _ => "utterance_only", + } + } + + const fn idempotence(self) -> bool { + matches!(self, Self::AppleLayer1FusionIdempotent) + } + + const fn stop_lane(self) -> ProductionReplayLane { + match self { + Self::AppleLayer1LocalFinalPass => ProductionReplayLane::LocalFinalPass, + _ => ProductionReplayLane::AppleLexicon, + } + } +} + +impl std::fmt::Display for ReplayProfile { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.token()) + } +} + +impl FromStr for ReplayProfile { + type Err = String; + + fn from_str(raw: &str) -> std::result::Result { + let normalized = raw.trim().replace('_', "-"); + [ + Self::AppleLayer0, + Self::AppleLayer1Inprocess, + Self::AppleLayer1Sidecar, + Self::AppleLayer1Remote, + Self::AppleLayer1FusionUtterance, + Self::AppleLayer1FusionLeftPad, + Self::AppleLayer1FusionStablePrompt, + Self::AppleLayer1FusionIdempotent, + Self::AppleLayer1LocalFinalPass, + ] + .into_iter() + .find(|profile| profile.token() == normalized) + .ok_or_else(|| format!("unknown replay profile {raw:?}")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ReferenceKind { + Human, + HistoricalSameStem, +} + +impl ReferenceKind { + const fn rank(self) -> u8 { + match self { + Self::Human => 0, + Self::HistoricalSameStem => 1, + } + } +} + +#[derive(Debug, Clone)] +struct Reference { + path: PathBuf, + sha256: String, + kind: ReferenceKind, +} + +#[derive(Debug, Clone)] +struct Clip { + path: PathBuf, + sha256: String, + reference: Option, + has_apple_reference: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CorpusCensus { + schema: String, + generated_at: String, + root_count: usize, + discovered_audio_instances: usize, + distinct_audio: usize, + duplicate_instances: usize, + format_instances: BTreeMap, + human_reference_instances: usize, + historical_reference_instances: usize, + apple_reference_instances: usize, + distinct_human_paired: usize, + distinct_historical_paired: usize, + distinct_apple_referenced: usize, + distinct_unpaired: usize, + selected_distinct: usize, + reference_policy: String, + privacy: PrivacyContract, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PrivacyContract { + source_paths_emitted: bool, + source_filenames_emitted: bool, + transcript_bodies_emitted: bool, + opaque_ids_are_hash_prefixes: bool, +} + +impl Default for PrivacyContract { + fn default() -> Self { + Self { + source_paths_emitted: false, + source_filenames_emitted: false, + transcript_bodies_emitted: false, + opaque_ids_are_hash_prefixes: true, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProfileReport { + schema: String, + engine_contract: String, + generated_at: String, + commit: String, + profile: ReplayProfile, + reference_policy: String, + corpus: CorpusCensus, + distinct_recordings: usize, + requested_runs_per_recording: usize, + requested_executions: usize, + successful_executions: usize, + failed_executions: usize, + total_audio_seconds_executed: f64, + total_tail_patches: usize, + requested_layered: bool, + observed_layered: bool, + profile_observation_matches: bool, + mean_wer: Option, + mean_cer: Option, + mean_character_parity: Option, + input_hashes_unchanged: bool, + settings_loaded: bool, + dotenv_loaded: bool, + keychain_disabled: bool, + apple_stt_bridge: FileFingerprint, + quality_html: String, + rows: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ExecutionRow { + opaque_id: String, + run: usize, + audio_sha256: String, + reference_sha256: String, + reference_kind: ReferenceKind, + duration_seconds: f64, + sample_rate_hz: u32, + status: String, + error_class: Option, + wall_seconds: f64, + events: usize, + previews: usize, + sealed_finals: usize, + final_count: usize, + unique_final_id_count: usize, + repeated_final_id_count: usize, + overlapping_final_window_count: usize, + tail_patches: usize, + layer1_provider_armed: bool, + live_chars: usize, + adjudicated_chars: usize, + delivered_chars: usize, + reference_tokens: usize, + delivered_tokens: usize, + token_ratio: f64, + head_present: bool, + tail_present: bool, + wer: f64, + cer: f64, + character_parity: f64, + teacher_similarity: f64, + final_pass_attempted: bool, + final_pass_skipped: bool, + lexicon_rewrites: u64, + gate_drops: u64, + audio_hash_unchanged: bool, + reference_hash_unchanged: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct FileFingerprint { + label: String, + exists: bool, + sha256: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProfileStatus { + profile: ReplayProfile, + worker_exit: Option, + report_present: bool, + successful_executions: usize, + failed_executions: usize, + observed_layered: Option, + mean_wer: Option, + mean_cer: Option, + mean_character_parity: Option, + quality_html: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct MatrixReport { + schema: String, + engine_contract: String, + generated_at: String, + commit: String, + corpus: CorpusCensus, + requested_profiles: usize, + completed_profiles: usize, + distinct_recordings: usize, + requested_runs_per_recording: usize, + requested_executions: usize, + successful_executions: usize, + failed_executions: usize, + profile_status: Vec, + configuration_files_before: Vec, + configuration_files_after: Vec, + configuration_files_unchanged: bool, + operator_settings_loaded: bool, + operator_dotenv_loaded: bool, + keychain_disabled: bool, + apple_stt_bridge: FileFingerprint, + permission_request_apis_called_by_tool: bool, + tcc_database_inspected: bool, + permission_state_proven_unchanged: bool, + quality_gate: &'static str, + coverage: CoverageContract, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CoverageContract { + production_pcm_session_replay: &'static str, + production_stop_adjudication: &'static str, + production_lexicon_delivery: &'static str, + coreaudio_microphone_capture: &'static str, + blackhole_loopback_capture: &'static str, + hold_toggle_hotkey_modes: &'static str, + clipboard_paste_and_target_app: &'static str, + cloud_gateway: &'static str, + inline_llm_formatting: &'static str, + tcc_permissions: &'static str, +} + +impl Default for CoverageContract { + fn default() -> Self { + Self { + production_pcm_session_replay: "covered", + production_stop_adjudication: "covered", + production_lexicon_delivery: "covered", + coreaudio_microphone_capture: "not_covered_by_file_replay", + blackhole_loopback_capture: "not_covered_by_file_replay", + hold_toggle_hotkey_modes: "not_covered_by_file_replay", + clipboard_paste_and_target_app: "not_covered_by_file_replay", + cloud_gateway: "covered_only_when_apple_layer1_remote_is_requested_and_configured", + inline_llm_formatting: "not_covered_by_current_replay_seam", + tcc_permissions: "not_mutated_or_fully_verified", + } + } +} + +fn main() -> ExitCode { + // SAFETY: this is the first executable statement, before Clap parsing, + // runtime construction or thread creation. Corpus tooling must be unable + // to read, write or prompt for the operator's production Keychain even if + // a future replay dependency unexpectedly reaches Config/secret code. + unsafe { + std::env::set_var("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + } + match run(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("codescribe-corpus: {error:#}"); + ExitCode::from(2) + } + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Census { + roots, + out, + include_historical, + } => { + let policy = if include_historical { + ReferencePolicy::HumanAndHistorical + } else { + ReferencePolicy::Human + }; + let discovery = discover_corpus(&roots, policy, None)?; + atomic_write_json(&out, &discovery.census)?; + atomic_write( + &markdown_sibling(&out), + census_markdown(&discovery.census).as_bytes(), + )?; + println!( + "corpus census: instances={} distinct={} selected={} report={}", + discovery.census.discovered_audio_instances, + discovery.census.distinct_audio, + discovery.census.selected_distinct, + out.display() + ); + Ok(()) + } + Command::Run { + roots, + out_dir, + profiles, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + } => run_matrix(MatrixArgs { + roots, + out_dir, + profiles, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + }), + Command::Worker { + roots, + out, + profile, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + } => { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("build replay runtime")?; + runtime.block_on(run_worker(WorkerArgs { + roots, + out, + profile, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + })) + } + } +} + +struct Discovery { + census: CorpusCensus, + selected: Vec, +} + +fn discover_corpus( + roots: &[PathBuf], + policy: ReferencePolicy, + max_recordings: Option, +) -> Result { + if roots.is_empty() { + bail!("at least one corpus root is required"); + } + let mut instances = Vec::new(); + for root in roots { + if !root.is_dir() { + bail!("corpus root is not a directory: {}", root.display()); + } + walk_audio(root, &mut instances)?; + } + instances.sort(); + + let mut format_instances = BTreeMap::new(); + let mut human_reference_instances = 0usize; + let mut historical_reference_instances = 0usize; + let mut apple_reference_instances = 0usize; + let mut distinct = BTreeMap::::new(); + + for path in &instances { + let extension = lower_extension(path).unwrap_or_else(|| "unknown".to_string()); + *format_instances.entry(extension.clone()).or_insert(0) += 1; + let audio_sha256 = sha256_file(path)?; + let human_path = reference_path(path, "_human_transcription.txt"); + let historical_path = path.with_extension("txt"); + let apple_path = reference_path(path, "_apple_live_reference.txt"); + let human = human_path.filter(|candidate| candidate.is_file()); + let historical = historical_path.is_file().then_some(historical_path); + let has_apple_reference = apple_path.is_some_and(|candidate| candidate.is_file()); + human_reference_instances += usize::from(human.is_some()); + historical_reference_instances += usize::from(historical.is_some()); + apple_reference_instances += usize::from(has_apple_reference); + + let reference = if let Some(reference_path) = human { + Some(Reference { + sha256: sha256_file(&reference_path)?, + path: reference_path, + kind: ReferenceKind::Human, + }) + } else if matches!(policy, ReferencePolicy::HumanAndHistorical) { + if let Some(reference_path) = historical { + Some(Reference { + sha256: sha256_file(&reference_path)?, + path: reference_path, + kind: ReferenceKind::HistoricalSameStem, + }) + } else { + None + } + } else { + None + }; + + let incoming = Clip { + path: path.clone(), + sha256: audio_sha256.clone(), + reference, + has_apple_reference, + }; + match distinct.get_mut(&audio_sha256) { + Some(existing) => merge_duplicate(existing, incoming)?, + None => { + distinct.insert(audio_sha256, incoming); + } + } + } + + let distinct_audio = distinct.len(); + let distinct_human_paired = distinct + .values() + .filter(|clip| { + matches!( + clip.reference.as_ref().map(|reference| reference.kind), + Some(ReferenceKind::Human) + ) + }) + .count(); + let distinct_historical_paired = distinct + .values() + .filter(|clip| { + matches!( + clip.reference.as_ref().map(|reference| reference.kind), + Some(ReferenceKind::HistoricalSameStem) + ) + }) + .count(); + let distinct_apple_referenced = distinct + .values() + .filter(|clip| clip.has_apple_reference) + .count(); + let distinct_unpaired = distinct + .values() + .filter(|clip| clip.reference.is_none()) + .count(); + let mut selected = distinct + .into_values() + .filter(|clip| clip.reference.is_some()) + .collect::>(); + selected.sort_by(|left, right| left.sha256.cmp(&right.sha256)); + if let Some(limit) = max_recordings { + selected.truncate(limit); + } + + let census = CorpusCensus { + schema: REPORT_SCHEMA.to_string(), + generated_at: Utc::now().to_rfc3339(), + root_count: roots.len(), + discovered_audio_instances: instances.len(), + distinct_audio, + duplicate_instances: instances.len().saturating_sub(distinct_audio), + format_instances, + human_reference_instances, + historical_reference_instances, + apple_reference_instances, + distinct_human_paired, + distinct_historical_paired, + distinct_apple_referenced, + distinct_unpaired, + selected_distinct: selected.len(), + reference_policy: policy.as_str().to_string(), + privacy: PrivacyContract::default(), + }; + Ok(Discovery { census, selected }) +} + +fn walk_audio(directory: &Path, output: &mut Vec) -> Result<()> { + let mut entries = fs::read_dir(directory) + .with_context(|| format!("read corpus directory {}", directory.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let file_type = entry.file_type()?; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + if file_type.is_dir() { + walk_audio(&path, output)?; + } else if file_type.is_file() && is_audio(&path) { + output.push(path); + } + } + Ok(()) +} + +fn is_audio(path: &Path) -> bool { + lower_extension(path).is_some_and(|extension| AUDIO_EXTENSIONS.contains(&extension.as_str())) +} + +fn lower_extension(path: &Path) -> Option { + path.extension() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) +} + +fn reference_path(audio: &Path, suffix: &str) -> Option { + let stem = audio.file_stem()?.to_str()?; + Some(audio.parent()?.join(format!("{stem}{suffix}"))) +} + +fn merge_duplicate(existing: &mut Clip, incoming: Clip) -> Result<()> { + existing.has_apple_reference |= incoming.has_apple_reference; + match (&existing.reference, &incoming.reference) { + (Some(left), Some(right)) if left.kind == right.kind && left.sha256 != right.sha256 => { + bail!( + "duplicate audio hash has conflicting {:?} reference hashes", + left.kind + ); + } + (Some(left), Some(right)) if right.kind.rank() < left.kind.rank() => { + existing.reference = Some(right.clone()); + } + (None, Some(reference)) => existing.reference = Some(reference.clone()), + _ => {} + } + Ok(()) +} + +struct MatrixArgs { + roots: Vec, + out_dir: PathBuf, + profiles: Vec, + runs: usize, + references: ReferencePolicy, + max_recordings: Option, + language: String, + commit: String, + apple_bridge: PathBuf, +} + +fn run_matrix(args: MatrixArgs) -> Result<()> { + if args.runs == 0 { + bail!("--runs must be greater than zero"); + } + if args.profiles.is_empty() { + bail!("at least one replay profile is required"); + } + let apple_stt_bridge = fingerprint_file("apple_stt_bridge", &args.apple_bridge)?; + if !apple_stt_bridge.exists { + bail!("--apple-bridge must name an existing file"); + } + fs::create_dir_all(&args.out_dir) + .with_context(|| format!("create report directory {}", args.out_dir.display()))?; + let discovery = discover_corpus(&args.roots, args.references, args.max_recordings)?; + if discovery.selected.is_empty() { + bail!( + "no recordings match reference policy {}", + args.references.as_str() + ); + } + + let config_before = operator_configuration_fingerprints()?; + let current_exe = std::env::current_exe().context("resolve corpus runner executable")?; + let mut profile_status = Vec::with_capacity(args.profiles.len()); + let mut completed_profiles = 0usize; + let mut successful_executions = 0usize; + let mut failed_executions = 0usize; + + for profile in &args.profiles { + let profile_out = args + .out_dir + .join(format!("profile-{}.json", profile.token())); + let runtime_dir = args.out_dir.join("runtime").join(profile.token()); + fs::create_dir_all(&runtime_dir)?; + let mut child = ProcessCommand::new(¤t_exe); + child + .arg("worker") + .arg("--out") + .arg(&profile_out) + .arg("--profile") + .arg(profile.token()) + .arg("--runs") + .arg(args.runs.to_string()) + .arg("--references") + .arg(args.references.as_str().replace('_', "-")) + .arg("--language") + .arg(&args.language) + .arg("--commit") + .arg(&args.commit) + .arg("--apple-bridge") + .arg(&args.apple_bridge) + .env("CODESCRIBE_DATA_DIR", &runtime_dir) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(limit) = args.max_recordings { + child.arg("--max-recordings").arg(limit.to_string()); + } + for root in &args.roots { + child.arg("--root").arg(root); + } + configure_profile_environment(&mut child, *profile, &args.apple_bridge); + let status = child + .status() + .with_context(|| format!("launch profile {}", profile.token()))?; + let report = fs::read(&profile_out) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if report.is_some() { + completed_profiles += 1; + } + let successful = report + .as_ref() + .map_or(0, |profile_report| profile_report.successful_executions); + let failed = report + .as_ref() + .map_or(discovery.selected.len() * args.runs, |profile_report| { + profile_report.failed_executions + }); + successful_executions += successful; + failed_executions += failed; + profile_status.push(ProfileStatus { + profile: *profile, + worker_exit: status.code(), + report_present: report.is_some(), + successful_executions: successful, + failed_executions: failed, + observed_layered: report + .as_ref() + .map(|profile_report| profile_report.observed_layered), + mean_wer: report + .as_ref() + .and_then(|profile_report| profile_report.mean_wer), + mean_cer: report + .as_ref() + .and_then(|profile_report| profile_report.mean_cer), + mean_character_parity: report + .as_ref() + .and_then(|profile_report| profile_report.mean_character_parity), + quality_html: report + .as_ref() + .map(|profile_report| profile_report.quality_html.clone()), + }); + } + + let config_after = operator_configuration_fingerprints()?; + let config_unchanged = config_before == config_after; + let requested_executions = discovery.selected.len() * args.runs * args.profiles.len(); + let matrix = MatrixReport { + schema: REPORT_SCHEMA.to_string(), + engine_contract: ENGINE_CONTRACT_ID.to_string(), + generated_at: Utc::now().to_rfc3339(), + commit: args.commit, + corpus: discovery.census, + requested_profiles: args.profiles.len(), + completed_profiles, + distinct_recordings: discovery.selected.len(), + requested_runs_per_recording: args.runs, + requested_executions, + successful_executions, + failed_executions, + profile_status, + configuration_files_before: config_before, + configuration_files_after: config_after, + configuration_files_unchanged: config_unchanged, + operator_settings_loaded: false, + operator_dotenv_loaded: false, + keychain_disabled: true, + apple_stt_bridge, + permission_request_apis_called_by_tool: false, + tcc_database_inspected: false, + permission_state_proven_unchanged: false, + quality_gate: "measurement_only_operator_decides", + coverage: CoverageContract::default(), + }; + let report_path = args.out_dir.join("report.json"); + atomic_write_json(&report_path, &matrix)?; + atomic_write( + &args.out_dir.join("report.md"), + matrix_markdown(&matrix).as_bytes(), + )?; + println!( + "corpus parity: distinct={} executions={}/{} config_unchanged={} report={}", + matrix.distinct_recordings, + matrix.successful_executions, + matrix.requested_executions, + matrix.configuration_files_unchanged, + report_path.display() + ); + if completed_profiles != args.profiles.len() { + bail!( + "{} of {} profile workers failed to publish a report", + args.profiles.len() - completed_profiles, + args.profiles.len() + ); + } + Ok(()) +} + +fn configure_profile_environment( + command: &mut ProcessCommand, + profile: ReplayProfile, + apple_bridge: &Path, +) { + for key in CONTROLLED_ENV { + command.env_remove(key); + } + if !matches!(profile, ReplayProfile::AppleLayer1Remote) { + command.env_remove("STT_API_KEY"); + command.env_remove("STT_ENDPOINT"); + command.env_remove("CODESCRIBE_STT_ENDPOINT"); + } + command + .env("CODESCRIBE_DISABLE_KEYCHAIN", "1") + .env("CODESCRIBE_STT_ENGINE", "apple") + .env("CODESCRIBE_APPLE_STT_BRIDGE", apple_bridge) + .env("CODESCRIBE_BRIDGE_DISCLAIM", "1") + .env( + "CODESCRIBE_LAYERED_TRANSCRIPTION", + if profile.layered() { "phase1" } else { "off" }, + ) + .env("STT_TAIL_PROVIDER", profile.tail_provider()) + .env( + "CODESCRIBE_SILERO_FUSION", + if profile.fusion() { "on" } else { "off" }, + ) + .env("CODESCRIBE_SILERO_FUSION_CONTEXT", profile.fusion_context()) + .env( + "CODESCRIBE_SPAN_IDEMPOTENCE", + if profile.idempotence() { "on" } else { "off" }, + ) + .env("CODESCRIBE_INLINE_FORMAT", "off") + .env("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", "off") + .env("CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", "0") + .env( + "FINAL_PASS_MODE", + if matches!(profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "always" + } else { + "off" + }, + ) + .env( + "CODESCRIBE_LOCAL_STT_FINAL_PASS", + if matches!(profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "1" + } else { + "0" + }, + ); +} + +struct WorkerArgs { + roots: Vec, + out: PathBuf, + profile: ReplayProfile, + runs: usize, + references: ReferencePolicy, + max_recordings: Option, + language: String, + commit: String, + apple_bridge: PathBuf, +} + +async fn run_worker(args: WorkerArgs) -> Result<()> { + if args.runs == 0 { + bail!("--runs must be greater than zero"); + } + validate_worker_environment(args.profile, &args.apple_bridge)?; + codescribe_core::stt::apple_stt::ensure_noninteractive_ready(Some(&args.language)) + .context("noninteractive Apple STT preflight")?; + let discovery = discover_corpus(&args.roots, args.references, args.max_recordings)?; + if discovery.selected.is_empty() { + bail!("worker selected no recordings"); + } + + let mut settings = UserSettings { + stt_engine: Some("apple".to_string()), + layered_transcription: Some(if args.profile.layered() { + "phase1".to_string() + } else { + "off".to_string() + }), + final_pass_mode: Some( + if matches!(args.profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "always" + } else { + "off" + } + .to_string(), + ), + ..UserSettings::default() + }; + // The cloud product session is a separate gateway surface. Tail-patch + // provider profiles are controlled by the explicit process environment. + settings.asr_mode = Some("apple_only".to_string()); + + let output_root = args + .out + .parent() + .ok_or_else(|| anyhow!("profile report path has no parent"))?; + let quality_dir = output_root.join("quality"); + let quality_audio_dir = quality_dir.join("audio"); + fs::create_dir_all(&quality_audio_dir).context("create private quality report directory")?; + make_private_directory(&quality_dir)?; + make_private_directory(&quality_audio_dir)?; + + let mut rows = Vec::with_capacity(discovery.selected.len() * args.runs); + let mut quality_entries = Vec::with_capacity(discovery.selected.len() * args.runs); + let mut total_audio_seconds_executed = 0.0; + for clip in &discovery.selected { + let reference = clip + .reference + .as_ref() + .expect("selected clips have references"); + let truth = fs::read_to_string(&reference.path).context("read paired reference")?; + let (samples, sample_rate) = codescribe_core::audio::load_audio_file(&clip.path) + .map_err(|_| anyhow!("decode replay audio failed"))?; + if samples.is_empty() || sample_rate == 0 { + bail!("selected replay audio is empty or has zero sample rate"); + } + let quality_audio_rel = publish_quality_audio(&quality_audio_dir, clip)?; + let duration_seconds = samples.len() as f64 / f64::from(sample_rate); + eprintln!( + "corpus replay selected: profile={} recording={} duration_seconds={duration_seconds:.3} runs={}", + args.profile.token(), + opaque_id(&clip.sha256), + args.runs + ); + for run in 1..=args.runs { + let started = Instant::now(); + let replay = replay_overlay_recording( + &clip.path, + Some(args.language.clone()), + &settings, + GatewaySessionAvailability::Unavailable, + args.profile.stop_lane(), + ) + .await; + let wall_seconds = started.elapsed().as_secs_f64(); + total_audio_seconds_executed += duration_seconds; + let execution = ReplayExecutionContext { + clip, + reference, + truth: &truth, + run, + profile: args.profile, + duration_seconds, + sample_rate, + wall_seconds, + audio_rel_path: &quality_audio_rel, + }; + match replay { + Ok(replay) => { + quality_entries.push(success_quality_entry(&execution, &replay)); + rows.push(success_row(&execution, replay)?); + } + Err(error) => { + quality_entries.push(failure_quality_entry(&execution, &format!("{error:#}"))); + rows.push(failure_row(&execution)?); + } + } + eprintln!( + "corpus replay: profile={} recording={} run={}/{} status={}", + args.profile.token(), + opaque_id(&clip.sha256), + run, + args.runs, + rows.last().map_or("missing", |row| row.status.as_str()) + ); + } + } + + let successful = rows.iter().filter(|row| row.status == "ok").count(); + let failed = rows.len() - successful; + let total_tail_patches = rows.iter().map(|row| row.tail_patches).sum(); + let observed_layered = total_tail_patches > 0; + let successful_rows = rows + .iter() + .filter(|row| row.status == "ok") + .collect::>(); + let mean_wer = mean(successful_rows.iter().map(|row| row.wer)); + let mean_cer = mean(successful_rows.iter().map(|row| row.cer)); + let mean_character_parity = mean(successful_rows.iter().map(|row| row.character_parity)); + let input_hashes_unchanged = rows + .iter() + .all(|row| row.audio_hash_unchanged && row.reference_hash_unchanged); + let quality_html = format!("quality/{}.html", args.profile.token()); + let quality_report = build_quality_report( + args.profile, + &args.language, + args.references, + quality_entries, + ); + let quality_config = QualityReportConfig { + input_dir: args.roots[0].clone(), + output_dir: quality_dir.clone(), + date_filter: None, + limit: 0, + language: Some(args.language.clone()), + skip_cloud: true, + cloud_concurrency: 0, + skip_formatting: true, + debug_mode: true, + copy_audio: false, + metrics_reference: MetricsReference::Corpus, + local_transcription: LocalTranscriptionMode::LocalWhisper, + }; + atomic_write_private( + &output_root.join(&quality_html), + render_qube_html(&quality_report, &quality_config).as_bytes(), + )?; + + let report = ProfileReport { + schema: REPORT_SCHEMA.to_string(), + engine_contract: ENGINE_CONTRACT_ID.to_string(), + generated_at: Utc::now().to_rfc3339(), + commit: args.commit, + profile: args.profile, + reference_policy: args.references.as_str().to_string(), + corpus: discovery.census, + distinct_recordings: discovery.selected.len(), + requested_runs_per_recording: args.runs, + requested_executions: discovery.selected.len() * args.runs, + successful_executions: successful, + failed_executions: failed, + total_audio_seconds_executed, + total_tail_patches, + requested_layered: args.profile.layered(), + observed_layered, + profile_observation_matches: observed_layered == args.profile.layered(), + mean_wer, + mean_cer, + mean_character_parity, + input_hashes_unchanged, + settings_loaded: false, + dotenv_loaded: false, + keychain_disabled: true, + apple_stt_bridge: fingerprint_file("apple_stt_bridge", &args.apple_bridge)?, + quality_html, + rows, + }; + atomic_write_json(&args.out, &report)?; + Ok(()) +} + +fn publish_quality_audio(quality_audio_dir: &Path, clip: &Clip) -> Result { + let extension = clip + .path + .extension() + .and_then(OsStr::to_str) + .unwrap_or("wav") + .to_ascii_lowercase(); + let file_name = format!("{}.{}", opaque_id(&clip.sha256), extension); + let published = quality_audio_dir.join(&file_name); + if fs::symlink_metadata(&published).is_ok() { + let existing = fs::canonicalize(&published) + .with_context(|| format!("resolve quality audio link {}", published.display()))?; + let expected = fs::canonicalize(&clip.path) + .with_context(|| format!("resolve corpus audio {}", clip.path.display()))?; + if existing != expected { + bail!( + "quality audio link collision for {}", + opaque_id(&clip.sha256) + ); + } + } else { + let source_root = clip + .path + .parent() + .ok_or_else(|| anyhow!("quality audio source has no parent"))?; + safe_symlink_or_copy_bounded(&clip.path, source_root, &published, quality_audio_dir) + .with_context(|| format!("publish private quality audio {}", published.display()))?; + } + Ok(format!("audio/{file_name}")) +} + +struct ReplayExecutionContext<'a> { + clip: &'a Clip, + reference: &'a Reference, + truth: &'a str, + run: usize, + profile: ReplayProfile, + duration_seconds: f64, + sample_rate: u32, + wall_seconds: f64, + audio_rel_path: &'a str, +} + +fn success_quality_entry( + execution: &ReplayExecutionContext<'_>, + replay: &codescribe::controller::production_replay::ProductionOverlayReplay, +) -> ReportEntry { + let raw_wer = word_error_rate(execution.truth, &replay.live_text) as f32; + let raw_cer = character_error_rate(execution.truth, &replay.live_text) as f32; + let post_wer = word_error_rate(execution.truth, &replay.delivered_text) as f32; + let post_cer = character_error_rate(execution.truth, &replay.delivered_text) as f32; + let raw_state = if replay.live_text.trim().is_empty() { + ReportTranscriptState::EmptyTranscript + } else { + ReportTranscriptState::TextCommitted + }; + ReportEntry { + id: format!( + "{}-run{}-{}", + opaque_id(&execution.clip.sha256), + execution.run, + execution.profile.token() + ), + audio_path: opaque_id(&execution.clip.sha256), + audio_rel_path: execution.audio_rel_path.to_string(), + reference_path: None, + duration_secs: execution.duration_seconds as f32, + transcripts: ReportTranscripts { + raw: Some(replay.live_text.clone()), + post: Some(replay.delivered_text.clone()), + ai_formatted: None, + cloud: None, + reference: Some(execution.truth.to_string()), + }, + raw_semantics: Some(ReportTranscriptSemantics { + state: raw_state, + reason: None, + }), + metrics: ReportMetrics { + raw_wer: Some(raw_wer), + raw_cer: Some(raw_cer), + post_wer: Some(post_wer), + post_cer: Some(post_cer), + ..ReportMetrics::default() + }, + postprocess_stats: Some(replay.postprocess_stats.clone()), + errors: Vec::new(), + } +} + +fn failure_quality_entry(execution: &ReplayExecutionContext<'_>, error: &str) -> ReportEntry { + ReportEntry { + id: format!( + "{}-run{}-{}", + opaque_id(&execution.clip.sha256), + execution.run, + execution.profile.token() + ), + audio_path: opaque_id(&execution.clip.sha256), + audio_rel_path: execution.audio_rel_path.to_string(), + reference_path: None, + duration_secs: execution.duration_seconds as f32, + transcripts: ReportTranscripts { + reference: Some(execution.truth.to_string()), + ..ReportTranscripts::default() + }, + raw_semantics: None, + metrics: ReportMetrics::default(), + postprocess_stats: None, + errors: vec![error.to_string()], + } +} + +fn build_quality_report( + profile: ReplayProfile, + language: &str, + references: ReferencePolicy, + entries: Vec, +) -> QualityReport { + let raw_wer = entries + .iter() + .filter_map(|entry| entry.metrics.raw_wer) + .collect::>(); + let raw_cer = entries + .iter() + .filter_map(|entry| entry.metrics.raw_cer) + .collect::>(); + let post_wer = entries + .iter() + .filter_map(|entry| entry.metrics.post_wer) + .collect::>(); + let post_cer = entries + .iter() + .filter_map(|entry| entry.metrics.post_cer) + .collect::>(); + let raw_text_committed = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::TextCommitted) + ) + }) + .count(); + let raw_no_speech_detected = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::NoSpeechDetected) + ) + }) + .count(); + let raw_quality_gate_dropped = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::QualityGateDropped) + ) + }) + .count(); + QualityReport { + generated_at: Utc::now().to_rfc3339(), + environment: ReportEnvironment { + stt_endpoint: None, + stt_api_key_present: false, + llm_formatting_endpoint: None, + llm_formatting_model: None, + llm_formatting_key_present: false, + local_model: None, + whisper_language: Some(language.to_string()), + metrics_reference: references.as_str().to_string(), + local_transcription: format!("production_overlay:{}", profile.token()), + }, + summary: ReportSummary { + total_files: entries.len(), + processed_files: entries + .iter() + .filter(|entry| entry.errors.is_empty()) + .count(), + avg_raw_wer: mean_f32(&raw_wer), + avg_post_wer: mean_f32(&post_wer), + avg_raw_cer: mean_f32(&raw_cer), + avg_post_cer: mean_f32(&post_cer), + raw_no_speech_detected, + raw_quality_gate_dropped, + raw_text_committed, + ..ReportSummary::default() + }, + entries, + } +} + +fn mean_f32(values: &[f32]) -> Option { + (!values.is_empty()).then(|| values.iter().sum::() / values.len() as f32) +} + +fn make_private_directory(path: &Path) -> Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).with_context(|| { + format!( + "set private report directory permissions {}", + path.display() + ) + }) +} + +fn validate_worker_environment(profile: ReplayProfile, apple_bridge: &Path) -> Result<()> { + let expected = [ + ("CODESCRIBE_STT_ENGINE", "apple"), + ( + "CODESCRIBE_LAYERED_TRANSCRIPTION", + if profile.layered() { "phase1" } else { "off" }, + ), + ("STT_TAIL_PROVIDER", profile.tail_provider()), + ( + "CODESCRIBE_SILERO_FUSION", + if profile.fusion() { "on" } else { "off" }, + ), + ( + "CODESCRIBE_SPAN_IDEMPOTENCE", + if profile.idempotence() { "on" } else { "off" }, + ), + ("CODESCRIBE_INLINE_FORMAT", "off"), + ("CODESCRIBE_DISABLE_KEYCHAIN", "1"), + ("CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", "0"), + ("CODESCRIBE_BRIDGE_DISCLAIM", "1"), + ]; + for (key, value) in expected { + if std::env::var(key).as_deref() != Ok(value) { + bail!("worker environment pin mismatch for {key}"); + } + } + if std::env::var_os("CODESCRIBE_APPLE_STT_BRIDGE").as_deref() != Some(apple_bridge.as_os_str()) + { + bail!("worker Apple STT bridge pin mismatch"); + } + let data_dir = std::env::var_os("CODESCRIBE_DATA_DIR") + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("worker requires isolated CODESCRIBE_DATA_DIR"))?; + fs::create_dir_all(PathBuf::from(data_dir)).context("create isolated data directory")?; + Ok(()) +} + +fn success_row( + execution: &ReplayExecutionContext<'_>, + replay: codescribe::controller::production_replay::ProductionOverlayReplay, +) -> Result { + let reference_tokens = normalized_words(execution.truth); + let delivered_tokens = normalized_words(&replay.delivered_text); + let head_present = reference_tokens + .iter() + .take(8) + .any(|token| delivered_tokens.contains(token)); + let tail_present = reference_tokens + .iter() + .rev() + .take(8) + .any(|token| delivered_tokens.contains(token)); + let token_ratio = delivered_tokens.len() as f64 / reference_tokens.len().max(1) as f64; + let previews = replay + .events + .iter() + .filter(|event| matches!(event, EngineEvent::Preview { .. })) + .count(); + let sealed_finals = replay + .events + .iter() + .filter(|event| matches!(event, EngineEvent::UtteranceFinal { .. })) + .count(); + let tail_patches = replay + .events + .iter() + .filter(|event| { + matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ) + }) + .count(); + let audio_hash_unchanged = sha256_file(&execution.clip.path)? == execution.clip.sha256; + let reference_hash_unchanged = + sha256_file(&execution.reference.path)? == execution.reference.sha256; + Ok(ExecutionRow { + opaque_id: opaque_id(&execution.clip.sha256), + run: execution.run, + audio_sha256: execution.clip.sha256.clone(), + reference_sha256: execution.reference.sha256.clone(), + reference_kind: execution.reference.kind, + duration_seconds: execution.duration_seconds, + sample_rate_hz: execution.sample_rate, + status: "ok".to_string(), + error_class: None, + wall_seconds: execution.wall_seconds, + events: replay.events.len(), + previews, + sealed_finals, + final_count: replay.boundary_evidence.final_count, + unique_final_id_count: replay.boundary_evidence.unique_final_id_count, + repeated_final_id_count: replay.boundary_evidence.repeated_final_id_count, + overlapping_final_window_count: replay.boundary_evidence.overlapping_final_window_count, + tail_patches, + layer1_provider_armed: replay.layer1_armed, + live_chars: replay.live_text.chars().count(), + adjudicated_chars: replay.adjudicated_text.chars().count(), + delivered_chars: replay.delivered_text.chars().count(), + reference_tokens: reference_tokens.len(), + delivered_tokens: delivered_tokens.len(), + token_ratio, + head_present, + tail_present, + wer: word_error_rate(execution.truth, &replay.delivered_text), + cer: character_error_rate(execution.truth, &replay.delivered_text), + character_parity: normalized_character_parity(execution.truth, &replay.delivered_text), + teacher_similarity: teacher_similarity(execution.truth, &replay.delivered_text), + final_pass_attempted: replay.final_pass_attempted, + final_pass_skipped: replay.final_pass_skipped, + lexicon_rewrites: replay.postprocess_stats.lexicon_rewrites, + gate_drops: replay.postprocess_stats.gate_drops, + audio_hash_unchanged, + reference_hash_unchanged, + }) +} + +fn failure_row(execution: &ReplayExecutionContext<'_>) -> Result { + Ok(ExecutionRow { + opaque_id: opaque_id(&execution.clip.sha256), + run: execution.run, + audio_sha256: execution.clip.sha256.clone(), + reference_sha256: execution.reference.sha256.clone(), + reference_kind: execution.reference.kind, + duration_seconds: execution.duration_seconds, + sample_rate_hz: execution.sample_rate, + status: "error".to_string(), + error_class: Some("production_replay_failed".to_string()), + wall_seconds: execution.wall_seconds, + events: 0, + previews: 0, + sealed_finals: 0, + final_count: 0, + unique_final_id_count: 0, + repeated_final_id_count: 0, + overlapping_final_window_count: 0, + tail_patches: 0, + layer1_provider_armed: false, + live_chars: 0, + adjudicated_chars: 0, + delivered_chars: 0, + reference_tokens: 0, + delivered_tokens: 0, + token_ratio: 0.0, + head_present: false, + tail_present: false, + wer: 0.0, + cer: 0.0, + character_parity: 0.0, + teacher_similarity: 0.0, + final_pass_attempted: false, + final_pass_skipped: false, + lexicon_rewrites: 0, + gate_drops: 0, + audio_hash_unchanged: sha256_file(&execution.clip.path)? == execution.clip.sha256, + reference_hash_unchanged: sha256_file(&execution.reference.path)? + == execution.reference.sha256, + }) +} + +fn edit_distance(reference: &[T], hypothesis: &[T]) -> usize { + let mut previous = (0..=hypothesis.len()).collect::>(); + let mut current = vec![0; hypothesis.len() + 1]; + for (row, expected) in reference.iter().enumerate() { + current[0] = row + 1; + for (column, actual) in hypothesis.iter().enumerate() { + current[column + 1] = if expected == actual { + previous[column] + } else { + 1 + previous[column] + .min(previous[column + 1]) + .min(current[column]) + }; + } + std::mem::swap(&mut previous, &mut current); + } + previous[hypothesis.len()] +} + +fn normalized_words(text: &str) -> Vec { + text.split(|character: char| !character.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_lowercase) + .collect() +} + +fn normalized_characters(text: &str) -> Vec { + text.chars() + .flat_map(char::to_lowercase) + .filter(|character| !character.is_whitespace()) + .collect() +} + +fn word_error_rate(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_words(reference); + let hypothesis = normalized_words(hypothesis); + edit_distance(&reference, &hypothesis) as f64 / reference.len().max(1) as f64 +} + +fn character_error_rate(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_characters(reference); + let hypothesis = normalized_characters(hypothesis); + edit_distance(&reference, &hypothesis) as f64 / reference.len().max(1) as f64 +} + +fn normalized_character_parity(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_characters(reference); + let hypothesis = normalized_characters(hypothesis); + let denominator = reference.len().max(hypothesis.len()).max(1); + (1.0 - edit_distance(&reference, &hypothesis) as f64 / denominator as f64).clamp(0.0, 1.0) +} + +fn teacher_similarity(reference: &str, hypothesis: &str) -> f64 { + use codescribe_core::quality::teacher::{AlignOp, align_words, tokenize}; + + let normalize = |text: &str| text.split_whitespace().collect::>().join(" "); + let reference_tokens = tokenize(&normalize(reference)); + let hypothesis_tokens = tokenize(&normalize(hypothesis)); + let equal = align_words(&reference_tokens, &hypothesis_tokens) + .iter() + .filter(|operation| matches!(operation, AlignOp::Equal { .. })) + .count(); + equal as f64 / reference_tokens.len().max(hypothesis_tokens.len()).max(1) as f64 +} + +fn mean(values: impl Iterator) -> Option { + let values = values.collect::>(); + (!values.is_empty()).then(|| values.iter().sum::() / values.len() as f64) +} + +fn opaque_id(sha256: &str) -> String { + format!("audio-{}", &sha256[..sha256.len().min(16)]) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = safe_open(path).with_context(|| format!("open input {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn sha256_plist_semantics(path: &Path) -> Result { + let output = ProcessCommand::new("/usr/bin/plutil") + .args(["-convert", "xml1", "-o", "-", "--"]) + .arg(path) + .output() + .with_context(|| format!("canonicalize preferences plist {}", path.display()))?; + if !output.status.success() { + bail!( + "plutil could not canonicalize preferences plist {} (status={})", + path.display(), + output.status + ); + } + Ok(format!("{:x}", Sha256::digest(&output.stdout))) +} + +fn operator_configuration_fingerprints() -> Result> { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("HOME is unavailable for configuration fingerprinting"))?; + let mut fingerprints = [ + ( + "settings_json", + home.join("Library/Application Support/Codescribe/settings.json"), + ), + ("dotenv", home.join(".codescribe/.env")), + ] + .into_iter() + .map(|(label, path)| fingerprint_file(label, &path)) + .collect::>>()?; + + let preferences = home.join("Library/Preferences/com.vetcoders.codescribe.plist"); + let exists = preferences.is_file(); + fingerprints.push(FileFingerprint { + label: "preferences_plist_semantic".to_string(), + exists, + sha256: exists + .then(|| sha256_plist_semantics(&preferences)) + .transpose()?, + }); + Ok(fingerprints) +} + +fn fingerprint_file(label: &str, path: &Path) -> Result { + let exists = path.is_file(); + Ok(FileFingerprint { + label: label.to_string(), + exists, + sha256: exists.then(|| sha256_file(path)).transpose()?, + }) +} + +fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec_pretty(value)?; + atomic_write(path, &bytes) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let file_name = path + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| anyhow!("report path has no UTF-8 filename"))?; + let temporary = parent.join(format!(".{file_name}.tmp-{}", std::process::id())); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .with_context(|| format!("create temporary report {}", temporary.display()))?; + io::Write::write_all(&mut file, bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + Ok(()) +} + +fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<()> { + atomic_write(path, bytes)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("set private report permissions {}", path.display())) +} + +fn markdown_sibling(json: &Path) -> PathBuf { + json.with_extension("md") +} + +fn census_markdown(census: &CorpusCensus) -> String { + format!( + "# Codescribe corpus census\n\n- Audio instances: {}\n- Distinct audio: {}\n- Duplicate instances: {}\n- Formats: {}\n- Human-paired distinct: {}\n- Historical-paired distinct: {}\n- Apple-referenced distinct: {}\n- Unpaired distinct: {}\n- Selected distinct: {}\n- Reference policy: `{}`\n- Source paths or filenames emitted: no\n- Transcript bodies emitted: no\n", + census.discovered_audio_instances, + census.distinct_audio, + census.duplicate_instances, + census + .format_instances + .iter() + .map(|(format, count)| format!("{format}={count}")) + .collect::>() + .join(", "), + census.distinct_human_paired, + census.distinct_historical_paired, + census.distinct_apple_referenced, + census.distinct_unpaired, + census.selected_distinct, + census.reference_policy, + ) +} + +fn matrix_markdown(report: &MatrixReport) -> String { + let mut output = String::new(); + writeln!(output, "# Codescribe corpus parity report\n").unwrap(); + writeln!(output, "- Engine contract: `{}`", report.engine_contract).unwrap(); + writeln!(output, "- Commit: `{}`", report.commit).unwrap(); + writeln!( + output, + "- Apple STT bridge SHA-256: `{}`", + report + .apple_stt_bridge + .sha256 + .as_deref() + .unwrap_or("missing") + ) + .unwrap(); + writeln!( + output, + "- Distinct recordings: {}", + report.distinct_recordings + ) + .unwrap(); + writeln!( + output, + "- Executions: {}/{} successful", + report.successful_executions, report.requested_executions + ) + .unwrap(); + writeln!( + output, + "- Operator config hashes unchanged: {}", + report.configuration_files_unchanged + ) + .unwrap(); + writeln!(output, "- Operator settings loaded: no").unwrap(); + writeln!(output, "- Operator dotenv loaded: no").unwrap(); + writeln!( + output, + "- Keychain access disabled: {}", + report.keychain_disabled + ) + .unwrap(); + writeln!( + output, + "- Permission request APIs called: {}", + report.permission_request_apis_called_by_tool + ) + .unwrap(); + writeln!( + output, + "- TCC state fully proven unchanged: {}", + report.permission_state_proven_unchanged + ) + .unwrap(); + writeln!(output, "- Quality gate: `{}`\n", report.quality_gate).unwrap(); + writeln!( + output, + "| Profile | OK | Failed | Observed L1 | Mean WER | Mean CER | Char parity | Qube quality |" + ) + .unwrap(); + writeln!(output, "|---|---:|---:|---|---:|---:|---:|---|").unwrap(); + for status in &report.profile_status { + writeln!( + output, + "| `{}` | {} | {} | {} | {} | {} | {} | {} |", + status.profile.token(), + status.successful_executions, + status.failed_executions, + optional_bool(status.observed_layered), + optional_score(status.mean_wer), + optional_score(status.mean_cer), + optional_score(status.mean_character_parity), + status + .quality_html + .as_deref() + .map_or_else(|| "n/a".to_string(), |path| format!("[open]({path})")), + ) + .unwrap(); + } + output.push_str( + "\n## Coverage boundary\n\nThis is production PCM-session replay through stop adjudication and lexicon delivery. It does not prove CoreAudio microphone capture, BlackHole loopback, hotkey modes, target-app paste, inline LLM formatting, or TCC continuity. Those surfaces remain explicit in `report.json`.\n", + ); + output +} + +fn optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "yes", + Some(false) => "no", + None => "n/a", + } +} + +fn optional_score(value: Option) -> String { + value.map_or_else(|| "n/a".to_string(), |score| format!("{score:.4}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_tokens_round_trip_without_hidden_defaults() { + for profile in [ + ReplayProfile::AppleLayer0, + ReplayProfile::AppleLayer1Inprocess, + ReplayProfile::AppleLayer1Sidecar, + ReplayProfile::AppleLayer1Remote, + ReplayProfile::AppleLayer1FusionUtterance, + ReplayProfile::AppleLayer1FusionLeftPad, + ReplayProfile::AppleLayer1FusionStablePrompt, + ReplayProfile::AppleLayer1FusionIdempotent, + ReplayProfile::AppleLayer1LocalFinalPass, + ] { + assert_eq!(profile.token().parse::(), Ok(profile)); + } + } + + #[test] + fn privacy_contract_is_fail_closed() { + let contract = PrivacyContract::default(); + assert!(!contract.source_paths_emitted); + assert!(!contract.source_filenames_emitted); + assert!(!contract.transcript_bodies_emitted); + assert!(contract.opaque_ids_are_hash_prefixes); + } + + #[test] + fn corpus_schema_carries_the_engine_contract() { + assert_eq!(REPORT_SCHEMA, CORPUS_REPORT_SCHEMA); + assert_eq!(REPORT_SCHEMA, "codescribe-corpus-parity/v3"); + assert_eq!(ENGINE_CONTRACT_ID, "the-engine/v1"); + } + + #[test] + fn edit_metrics_distinguish_loss_from_exact_text() { + assert_eq!(word_error_rate("alpha beta", "alpha beta"), 0.0); + assert!(word_error_rate("alpha beta", "alpha") > 0.0); + assert_eq!(normalized_character_parity("Alpha beta", "alpha beta"), 1.0); + } + + #[test] + fn coverage_never_claims_file_replay_is_live_capture() { + let coverage = CoverageContract::default(); + assert_eq!(coverage.production_pcm_session_replay, "covered"); + assert_eq!( + coverage.coreaudio_microphone_capture, + "not_covered_by_file_replay" + ); + assert_eq!(coverage.tcc_permissions, "not_mutated_or_fully_verified"); + } + + #[test] + fn isolated_data_dir_is_not_removed_with_profile_overrides() { + assert!(!CONTROLLED_ENV.contains(&"CODESCRIBE_DATA_DIR")); + assert!(!CONTROLLED_ENV.contains(&"CODESCRIBE_DISABLE_KEYCHAIN")); + } + + #[test] + fn plist_fingerprint_ignores_storage_encoding() { + let temp = tempfile::tempdir().unwrap(); + let xml_path = temp.path().join("preferences.xml.plist"); + let binary_path = temp.path().join("preferences.binary.plist"); + fs::write( + &xml_path, + br#" + + + + agentEnabled + + launchCount + 7 + + +"#, + ) + .unwrap(); + fs::copy(&xml_path, &binary_path).unwrap(); + let conversion = ProcessCommand::new("/usr/bin/plutil") + .args(["-convert", "binary1", "--"]) + .arg(&binary_path) + .status() + .unwrap(); + assert!(conversion.success()); + assert_ne!( + sha256_file(&xml_path).unwrap(), + sha256_file(&binary_path).unwrap() + ); + assert_eq!( + sha256_plist_semantics(&xml_path).unwrap(), + sha256_plist_semantics(&binary_path).unwrap() + ); + } + + #[test] + fn production_quality_report_uses_qube_keyboard_surface() { + let entries = vec![ReportEntry { + id: "audio-deadbeef-run1-apple-layer0".to_string(), + audio_path: "audio-deadbeef".to_string(), + audio_rel_path: "audio/audio-deadbeef.wav".to_string(), + reference_path: None, + duration_secs: 1.0, + transcripts: ReportTranscripts { + raw: Some("surowy tekst".to_string()), + post: Some("dostarczony tekst".to_string()), + reference: Some("tekst człowieka".to_string()), + ..ReportTranscripts::default() + }, + raw_semantics: Some(ReportTranscriptSemantics { + state: ReportTranscriptState::TextCommitted, + reason: None, + }), + metrics: ReportMetrics { + raw_wer: Some(0.5), + post_wer: Some(0.25), + ..ReportMetrics::default() + }, + postprocess_stats: None, + errors: Vec::new(), + }]; + let report = build_quality_report( + ReplayProfile::AppleLayer0, + "pl", + ReferencePolicy::Human, + entries, + ); + let config = QualityReportConfig { + input_dir: PathBuf::from("."), + output_dir: PathBuf::from("."), + date_filter: None, + limit: 0, + language: Some("pl".to_string()), + skip_cloud: true, + cloud_concurrency: 0, + skip_formatting: true, + debug_mode: true, + copy_audio: false, + metrics_reference: MetricsReference::Corpus, + local_transcription: LocalTranscriptionMode::LocalWhisper, + }; + let html = render_qube_html(&report, &config); + assert!(html.contains("Ctrl+Cmd+Space")); + assert!(html.contains("event.code === 'ArrowLeft'")); + assert!(html.contains("surowy tekst")); + assert!(html.contains("dostarczony tekst")); + assert!(html.contains("tekst człowieka")); + } +} diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 1f844327..eba43bbb 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -39,6 +39,7 @@ use crate::{CsError, CsLanguage}; /// error, because at least one app-data root has already moved and the Rust /// process fence is permanently latched. const RESET_RELAUNCH_REQUIRED_MARKER: &str = "CODESCRIBE_RESET_RELAUNCH_REQUIRED"; +const AGENT_RESET_RELAUNCH_REQUIRED_MARKER: &str = "CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED"; /// Preserve the ordinary error contract before the destructive boundary, but /// mark every post-boundary error so the host cannot leave a half-reset, @@ -53,6 +54,16 @@ fn reset_error(reset: &AppDataResetGuard, message: impl Into) -> CsError CsError::Config { msg } } +fn agent_reset_error(mutation_started: bool, message: impl Into) -> CsError { + let message = message.into(); + let msg = if mutation_started { + format!("{AGENT_RESET_RELAUNCH_REQUIRED_MARKER}: {message}") + } else { + message + }; + CsError::Config { msg } +} + /// Full settings snapshot pushed to the Swift Settings UI. Combines real /// `Config` struct fields (settings.json / .env / defaults already merged by /// `Config::load()`) with env-only knobs read from persisted settings / .env @@ -166,6 +177,17 @@ pub struct CsResetPreview { pub total_bytes: u64, } +/// Non-secret impact summary for the narrowly-scoped Agent reset. Unlike the +/// app-data reset, this never counts or touches recordings, transcripts, +/// prompts, lexicon data, license state, or dictation preferences. +#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)] +pub struct CsAgentResetPreview { + pub threads: u64, + pub files: u64, + pub total_bytes: u64, + pub secrets_present: bool, +} + /// UI-safe view of one base prompt. Content is included because this surface is /// the prompt editor itself; audit records never include it. #[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)] @@ -1226,6 +1248,74 @@ impl CodescribeConfig { reset_preview_for_dirs(&app_data_dirs()) } + /// Preview the Agent-only reset without changing disk or Keychain state. + pub fn reset_agent_preview(&self) -> CsAgentResetPreview { + agent_reset_preview_for_paths(&agent_reset_paths()) + } + + /// Reset only durable Agent state. Conversations and tool/MCP files are + /// moved to Trash; provider credentials are deleted from Keychain and are + /// intentionally not recoverable. This deliberately does not use the + /// full-app reset fence or its broad root move. + pub fn reset_agent_data(&self) -> Result<(), CsError> { + // Resolve connector-owned Keychain accounts before `mcp.json` is moved. + // A malformed config fails closed while no destructive mutation has + // started; otherwise its bearer tokens would become invisible orphans. + let mut secret_accounts: Vec = agent_secret_accounts() + .iter() + .map(|account| (*account).to_string()) + .collect(); + secret_accounts.extend( + agent_connector_secret_accounts() + .map_err(|error| agent_reset_error(false, error.to_string()))?, + ); + let trash = codescribe_trash_dir()?; + let destination = create_agent_reset_destination(&trash)?; + let paths = agent_reset_paths(); + let mut mutation_started = false; + + for source in &paths { + if source.exists() { + let name = source.file_name().ok_or_else(|| { + agent_reset_error(mutation_started, "Agent reset path has no filename") + })?; + let target = unique_destination(&destination, &name.to_string_lossy(), None); + move_path_recoverably(source, &target).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to move Agent data to Trash: {error}"), + ) + })?; + mutation_started = true; + } + } + + clear_agent_settings().map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to clear Agent settings: {error}"), + ) + })?; + mutation_started = true; + Config::remove_env_keys(agent_env_keys()).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to clear Agent .env settings: {error}"), + ) + })?; + + for account in &secret_accounts { + mutation_started = true; + delete_key(account).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to remove Agent secret {account}: {error}"), + ) + })?; + } + Ok(()) + } + /// Move only `mcp.json` to Trash. This intentionally does not touch any /// other config, transcripts, threads, logs, preferences, or Keychain keys. pub fn clear_mcp_configuration(&self) -> Result<(), CsError> { @@ -1476,6 +1566,137 @@ fn app_data_dirs() -> Vec { selected.into_iter().map(|(path, _)| path).collect() } +/// The complete durable Agent-owned file surface. Keep this explicit: a reset +/// is allowed to move only these paths, never a parent data root. +fn agent_reset_paths() -> Vec { + let data = UserSettings::settings_dir(); + let config = Config::config_dir(); + vec![ + // `ThreadStore` keeps attachments at `threads/blobs/`; moving the + // parent once avoids a second overlapping move and preserves recovery. + data.join("threads"), + config.join("mcp.json"), + config.join("tool_grants.json"), + ] +} + +fn agent_secret_accounts() -> &'static [&'static str] { + &[ + "LLM_ASSISTIVE_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_XAI_API_KEY", + account_auth::OPENAI_ACCOUNT_TOKENS_ACCOUNT, + account_auth::ANTHROPIC_ACCOUNT_TOKENS_ACCOUNT, + account_auth::XAI_ACCOUNT_TOKENS_ACCOUNT, + ] +} + +/// Connector tokens created by the MCP Settings UI use a private account +/// namespace and are referenced from `mcp.json`. Delete only those app-owned +/// accounts: a hand-written auth_ref may deliberately point at a shared secret +/// such as GITHUB_TOKEN and Reset Agent must not infer ownership of it. +fn agent_connector_secret_accounts() -> anyhow::Result> { + let mut accounts: Vec = + codescribe_core::mcp::list_servers_at(&Config::config_dir().join("mcp.json"))? + .into_iter() + .filter_map(|server| server.auth_ref) + .filter(|account| account.starts_with("MCP_CONNECTOR_") && account.ends_with("_TOKEN")) + .collect(); + accounts.sort(); + accounts.dedup(); + Ok(accounts) +} + +/// Legacy/power-user Agent rows that can otherwise outlive settings.json and +/// become the effective provider again after relaunch. Keep this list narrow: +/// dictation, formatting, audio and hotkey rows are intentionally absent. +fn agent_env_keys() -> &'static [&'static str] { + &[ + "LLM_ASSISTIVE_ENDPOINT", + "LLM_ASSISTIVE_MODEL", + "LLM_ASSISTIVE_PROVIDER", + "LLM_ASSISTIVE_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_XAI_API_KEY", + "LLM_OPENAI_ACCOUNT_TOKENS", + "LLM_ANTHROPIC_ACCOUNT_TOKENS", + "LLM_XAI_ACCOUNT_TOKENS", + "LLM_OPENAI_OAUTH_CLIENT_ID", + "LLM_ANTHROPIC_OAUTH_CLIENT_ID", + "LLM_XAI_OAUTH_CLIENT_ID", + "AGENT_WORKSPACE_ROOTS", + "AGENT_ENTER_SENDS", + ] +} + +fn agent_reset_preview_for_paths(paths: &[PathBuf]) -> CsAgentResetPreview { + let (connector_accounts, connector_scan_failed) = match agent_connector_secret_accounts() { + Ok(accounts) => (accounts, false), + Err(_) => (Vec::new(), true), + }; + let mut preview = CsAgentResetPreview { + secrets_present: connector_scan_failed + || agent_secret_accounts() + .iter() + .map(|account| (*account).to_string()) + .chain(connector_accounts) + .any(|account| { + codescribe_core::config::keychain::load_key(&account) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + }), + ..CsAgentResetPreview::default() + }; + + for path in paths { + if path.ends_with("threads") { + preview.threads = preview + .threads + .saturating_add(thread_index_count(&path.join("index.json"))); + } + preview.files = preview.files.saturating_add(path_file_count(path)); + preview.total_bytes = preview.total_bytes.saturating_add(path_size(path)); + } + preview +} + +fn path_file_count(path: &Path) -> u64 { + let Ok(metadata) = fs::symlink_metadata(path) else { + return 0; + }; + if metadata.file_type().is_symlink() || metadata.is_file() { + return 1; + } + if !metadata.is_dir() { + return 0; + } + fs::read_dir(path) + .ok() + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| path_file_count(&entry.path())) + .sum() +} + +/// Remove only settings that define an Agent identity, its provider, its +/// workspace and its tool policy. All transcription, audio, hotkey, lexicon, +/// license and other ordinary app settings remain in the same JSON document. +fn clear_agent_settings() -> anyhow::Result<()> { + UserSettings::remove_agent_owned_state() +} + +fn create_agent_reset_destination(trash: &Path) -> anyhow::Result { + fs::create_dir_all(trash)?; + let destination = unique_destination( + trash, + &format!("codescribe-agent-reset-{}", timestamp_slug(&Utc::now())), + None, + ); + fs::create_dir_all(&destination)?; + Ok(destination) +} + /// The user's `~/.Trash`, which is where a reset parks data so it stays /// recoverable. Fails loudly when the home directory cannot be resolved rather /// than inventing a fallback destination. @@ -2208,13 +2429,15 @@ fn ensure_known_account(account: &str) -> Result<(), CsError> { #[cfg(test)] mod reset_tests { use super::{ - CsResetPreview, ResetAuditEvent, app_data_dirs, append_reset_audit, capture_base_prompts, - clear_mcp_configuration_to, create_reset_destination, move_path_recoverably_with, + CsResetPreview, ResetAuditEvent, agent_connector_secret_accounts, agent_env_keys, + agent_reset_error, agent_reset_paths, agent_reset_preview_for_paths, agent_secret_accounts, + app_data_dirs, append_reset_audit, capture_base_prompts, clear_mcp_configuration_to, + create_reset_destination, move_path_recoverably, move_path_recoverably_with, move_reset_dirs_to_destination, remove_path_without_following_symlinks, reset_preview_for_dirs, restore_base_prompts, }; use chrono::{DateTime, Utc}; - use codescribe_core::config::begin_app_data_reset; + use codescribe_core::config::{Config, begin_app_data_reset}; use serial_test::serial; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; @@ -2277,6 +2500,130 @@ mod reset_tests { .expect("valid fixed reset timestamp") } + #[test] + #[serial] + fn agent_reset_scope_moves_only_agent_files_and_keeps_dictation_roots() { + let sandbox = scratch("agent_scope"); + let root = sandbox.join("data"); + let trash = sandbox.join("trash"); + write( + &root.join("threads/index.json"), + br#"{"threads":[{"id":"one"}]}"#, + ); + write(&root.join("threads/one.json"), b"agent conversation"); + write(&root.join("threads/blobs/image.png"), b"agent attachment"); + write(&root.join("mcp.json"), b"{\"mcpServers\":{}}"); + write(&root.join("tool_grants.json"), b"{\"always_allow\":{}}"); + write( + &root.join("transcriptions/2026-08-15/voice.txt"), + b"must survive", + ); + write(&root.join("prompts/assistive.txt"), b"must survive"); + write(&root.join("dictionary/custom.json"), b"must survive"); + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); + + let paths = agent_reset_paths(); + let preview = agent_reset_preview_for_paths(&paths); + assert_eq!(preview.threads, 1); + assert_eq!(preview.files, 5); + + let destination = trash.join("codescribe-agent-reset-test"); + std::fs::create_dir_all(&destination).expect("create agent Trash destination"); + for source in &paths { + if source.exists() { + let name = source.file_name().expect("Agent fixture path name"); + move_path_recoverably(source, &destination.join(name)) + .expect("move only Agent-owned path"); + } + } + + assert!(destination.join("threads/one.json").is_file()); + assert!(destination.join("mcp.json").is_file()); + assert!(root.join("transcriptions/2026-08-15/voice.txt").is_file()); + assert!(root.join("prompts/assistive.txt").is_file()); + assert!(root.join("dictionary/custom.json").is_file()); + let _ = std::fs::remove_dir_all(&sandbox); + } + + #[test] + fn agent_reset_secret_scope_excludes_stt_and_non_agent_keys() { + let accounts = agent_secret_accounts(); + assert!(accounts.contains(&"LLM_ASSISTIVE_API_KEY")); + assert!(accounts.contains(&"LLM_OPENAI_ACCOUNT_TOKENS")); + assert!(!accounts.contains(&"STT_API_KEY")); + assert!(!accounts.contains(&"LLM_API_KEY")); + assert!(!accounts.contains(&"GITHUB_TOKEN")); + } + + #[test] + #[serial] + fn agent_reset_connector_secret_scope_uses_only_managed_auth_refs() { + let sandbox = scratch("agent_connector_secret_scope"); + write( + &sandbox.join("mcp.json"), + br#"{ + "mcpServers": { + "managed": { + "command": "managed", + "args": [], + "auth_ref": "MCP_CONNECTOR_MANAGED_TOKEN" + }, + "shared": { + "command": "shared", + "args": [], + "auth_ref": "GITHUB_TOKEN" + }, + "handwritten": { + "command": "handwritten", + "args": [], + "auth_ref": "my_custom_secret" + } + } + }"#, + ); + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &sandbox); + + assert_eq!( + agent_connector_secret_accounts().expect("read connector accounts"), + vec!["MCP_CONNECTOR_MANAGED_TOKEN"] + ); + let _ = std::fs::remove_dir_all(&sandbox); + } + + #[test] + fn agent_reset_marks_only_post_mutation_failures_for_relaunch() { + assert!( + !format!("{:?}", agent_reset_error(false, "prepare failed")) + .contains("CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED") + ); + assert!( + format!("{:?}", agent_reset_error(true, "Keychain failed")) + .contains("CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED") + ); + } + + #[test] + #[serial] + fn agent_reset_env_scope_removes_only_agent_rows() { + let sandbox = scratch("agent_env"); + let env_path = sandbox.join(".env"); + write( + &env_path, + b"# preserve this comment\nLLM_ASSISTIVE_MODEL=agent-model\nAGENT_WORKSPACE_ROOTS=~/work\nSTT_ENDPOINT=https://stt.example\nWHISPER_LANGUAGE=pl\nLLM_FORMATTING_MODEL=formatter\n", + ); + let _env_path = EnvGuard::set("CODESCRIBE_ENV_PATH", &env_path); + + Config::remove_env_keys(agent_env_keys()).expect("remove only Agent env rows"); + let rewritten = std::fs::read_to_string(&env_path).expect("read rewritten env"); + assert!(!rewritten.contains("LLM_ASSISTIVE_MODEL=")); + assert!(!rewritten.contains("AGENT_WORKSPACE_ROOTS=")); + assert!(rewritten.contains("# preserve this comment")); + assert!(rewritten.contains("STT_ENDPOINT=https://stt.example")); + assert!(rewritten.contains("WHISPER_LANGUAGE=pl")); + assert!(rewritten.contains("LLM_FORMATTING_MODEL=formatter")); + let _ = std::fs::remove_dir_all(&sandbox); + } + /// The reset scope follows `CODESCRIBE_DATA_DIR`, previews live counts, and /// moves the complete source into a recoverable Trash destination. #[test] diff --git a/core/build.rs b/core/build.rs index e597181f..ec2efba9 100644 --- a/core/build.rs +++ b/core/build.rs @@ -8,7 +8,10 @@ //! (`resolve_runtime_whisper_model_path`) — the model is held in memory for the //! session anyway, so baking ~1GB into every artifact only multiplied target/ //! into tens of GB for zero runtime win (2026-06-10 policy, operator-decided). -//! Release builds still embed Silero VAD + MiniLM embedder by default. +//! MiniLM follows the same runtime-load policy as Whisper: normal builds load +//! it from the app resource bundle or HF cache; only +//! `CODESCRIBE_EMBED_EMBEDDER=1` bakes it into the Rust artifact. Silero VAD +//! remains embedded because it is small and part of capture identity. //! Opt-out of all optional embedding with CODESCRIBE_NO_EMBED=1 (except Silero). //! TTS requires opt-in via CODESCRIBE_EMBED_TTS. //! @@ -45,10 +48,10 @@ const DEFAULT_TTS_REPO: &str = "sesame/csm-1b"; /// Hugging Face repo id for Mimi codec weights used with TTS embedding. const DEFAULT_MIMI_REPO: &str = "kyutai/mimi"; -/// Default embedder model — MiniLM multilingual (~224MB fp16, always embedded like Silero) +/// Default embedder model — MiniLM multilingual (~471MB fp32 weights on disk). /// Override with CODESCRIBE_EMBEDDER_REPO for alternative models const DEFAULT_EMBEDDER_MODEL_NAME: &str = "minilm-l12-v2"; -/// Default sentence-transformers MiniLM repo embedded unless `CODESCRIBE_NO_EMBED`. +/// Default sentence-transformers MiniLM repo resolved from bundle/cache at runtime. const DEFAULT_EMBEDDER_REPO: &str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"; /// Env flag: local install path skips release license-key hardening in `main`. const LOCAL_INSTALL_ENV: &str = "CODESCRIBE_LOCAL_INSTALL"; @@ -57,8 +60,8 @@ const LOCAL_INSTALL_ENV: &str = "CODESCRIBE_LOCAL_INSTALL"; /// compiles against. /// /// Each asset has its own policy, and they are not symmetrical: Silero VAD is -/// non-negotiable and its absence panics; MiniLM embeds by default; Whisper and -/// TTS are opt-in. A requested-but-missing model degrades to a `cargo:warning` +/// non-negotiable and its absence panics; MiniLM, Whisper and TTS are opt-in. +/// A requested-but-missing model degrades to a `cargo:warning` /// and a runtime lookup rather than failing the build — only the committed /// Silero file is treated as a repo invariant. fn main() { @@ -67,6 +70,7 @@ fn main() { println!("cargo:rerun-if-env-changed=CODESCRIBE_MODEL_PATH"); println!("cargo:rerun-if-env-changed=CODESCRIBE_NO_EMBED"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_WHISPER"); + println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_EMBEDDER"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_TTS"); println!("cargo:rerun-if-env-changed=CODESCRIBE_TTS_PATH"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBEDDER_REPO"); @@ -212,8 +216,9 @@ fn main() { ); } - // MiniLM embedder — always embedded (like Silero), ~224MB fp16 - // Skip only with CODESCRIBE_NO_EMBED=1 + // MiniLM embedder — runtime bundle/cache by default, matching Whisper. + // Binary embedding is an explicit fat-SKU/debug request only. + let embed_embedder_requested = env_flag("CODESCRIBE_EMBED_EMBEDDER", false); let embedder_repo = env::var("CODESCRIBE_EMBEDDER_REPO") .ok() .map(|v| v.trim().to_string()) @@ -226,7 +231,8 @@ fn main() { && embedder_model_path.join("tokenizer.json").exists() && embedder_model_path.join("model.safetensors").exists(); - if !no_embed && embedder_model_exists { + let embedder_embedded = embed_embedder_requested && !no_embed && embedder_model_exists; + if embedder_embedded { println!( "cargo:warning=Embedding MiniLM model from: {}", embedder_model_path.display() @@ -247,7 +253,7 @@ fn main() { fs::write(&embedder_dest_path, embedder_content) .expect("Failed to write embedded_embedder_data.rs"); println!("cargo:rustc-cfg=embed_embedder"); - } else if !no_embed && !embedder_model_exists { + } else if embed_embedder_requested && !no_embed && !embedder_model_exists { println!( "cargo:warning=Embedder model not found at: {}", embedder_model_path.display() @@ -323,12 +329,12 @@ fn main() { }; let embedder_summary = if qube_context { "not_used" - } else if !no_embed && embedder_model_exists { + } else if embedder_embedded { "embedded" - } else if no_embed { - "runtime_load_from_cache" - } else { + } else if embed_embedder_requested && !no_embed { "missing_at_build_time" + } else { + "runtime_load_from_bundle_or_cache" }; let tts_summary = if qube_context { "not_used" diff --git a/core/config/loader.rs b/core/config/loader.rs index dcdcd222..e7bfea76 100644 --- a/core/config/loader.rs +++ b/core/config/loader.rs @@ -1452,6 +1452,41 @@ impl Config { Ok(()) } + /// Remove a narrow set of persisted `.env` rows while preserving every + /// unrelated user-written row, comment and ordering. Used by scoped reset + /// flows; callers must name their owned keys explicitly. + pub fn remove_env_keys(keys: &[&str]) -> anyhow::Result<()> { + use crate::safe_path::{safe_read_to_string_bounded, safe_write_bounded}; + + let _data_io = super::storage_reset::begin_app_data_io()?; + let _persistence = config_persistence_guard(); + let path = Self::env_path(); + if !path.exists() { + return Ok(()); + } + let path = path.canonicalize()?; + let root = path + .parent() + .map(|parent| parent.to_path_buf()) + .unwrap_or_else(Self::config_dir); + let contents = safe_read_to_string_bounded(&path, &root)?; + let owned: HashSet<&str> = keys.iter().copied().collect(); + let output = contents + .lines() + .filter(|line| { + let key = line.trim().split_once('=').map(|(key, _)| key.trim()); + !key.is_some_and(|key| owned.contains(key)) + }) + .collect::>() + .join("\n"); + let output = if output.is_empty() { + String::new() + } else { + format!("{output}\n") + }; + safe_write_bounded(&path, &root, &output) + } + /// Migrate legacy keys inside .env to the current contract. fn migrate_env_legacy_keys() { let env_path = Self::env_path(); diff --git a/core/config/settings.rs b/core/config/settings.rs index fde3b770..8e14f1f0 100644 --- a/core/config/settings.rs +++ b/core/config/settings.rs @@ -1235,6 +1235,83 @@ impl UserSettings { self.save_unlocked() } + /// Remove only Agent-owned fields from the persisted JSON document. + /// + /// This intentionally edits the raw JSON value instead of doing a + /// `load()` -> `save()` round-trip. `load()` is fail-soft and returns + /// defaults for malformed input; using it in a destructive reset could + /// therefore replace an unreadable settings file with defaults and erase + /// unrelated user choices. Unknown fields and every non-Agent subtree are + /// preserved value-for-value. A malformed document is left untouched. + pub fn remove_agent_owned_state() -> anyhow::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io()?; + let _settings_io = settings_io_lock(); + let path = Self::settings_path(); + if !path.exists() { + return Ok(()); + } + + let contents = fs::read_to_string(&path)?; + let mut value: serde_json::Value = serde_json::from_str(&contents)?; + let is_v2 = value.get("schema_version").is_some(); + + if is_v2 { + let before: SettingsV2 = serde_json::from_value(value.clone())?; + Self::validate_v2(&before)?; + } else { + let _: Self = serde_json::from_value(value.clone())?; + } + + let mut changed = false; + if is_v2 { + changed |= remove_json_keys_at( + &mut value, + &["speech", "assistive"], + &["llm_endpoint", "llm_model", "provider"], + )?; + changed |= remove_json_keys_at( + &mut value, + &["system"], + &[ + "agent_workspace_roots", + "openai_oauth_client_id", + "anthropic_oauth_client_id", + "xai_oauth_client_id", + ], + )?; + changed |= + remove_json_keys_at(&mut value, &["agent"], &["permissions", "capabilities"])?; + changed |= remove_json_keys_at(&mut value, &["interaction"], &["agent_enter_sends"])?; + + let after: SettingsV2 = serde_json::from_value(value.clone())?; + Self::validate_v2(&after)?; + } else { + changed |= remove_json_keys_at( + &mut value, + &[], + &[ + "llm_assistive_endpoint", + "llm_assistive_model", + "llm_assistive_provider", + "openai_oauth_client_id", + "anthropic_oauth_client_id", + "xai_oauth_client_id", + "agent_workspace_roots", + "agent_permissions", + "agent_capabilities", + "agent_enter_sends", + ], + )?; + let _: Self = serde_json::from_value(value.clone())?; + } + + if !changed { + return Ok(()); + } + let json = serde_json::to_string_pretty(&value)?; + Self::write_json_atomic(&path, &json) + } + /// Persist while the settings transaction lock and app-data admission are held. fn save_unlocked(&self) -> anyhow::Result<()> { let dir = Self::settings_dir(); @@ -1583,6 +1660,38 @@ impl UserSettings { } } +/// Remove named keys from an existing JSON object reached by `path`. Missing +/// sections are a no-op; a present non-object is an error so reset never +/// normalizes malformed state by destroying sibling settings. +fn remove_json_keys_at( + value: &mut serde_json::Value, + path: &[&str], + keys: &[&str], +) -> anyhow::Result { + let mut current = value; + for component in path { + let Some(next) = current.get_mut(*component) else { + return Ok(false); + }; + current = next; + } + let object = current.as_object_mut().ok_or_else(|| { + anyhow::anyhow!( + "settings path {} must be an object", + if path.is_empty() { + "".to_string() + } else { + path.join(".") + } + ) + })?; + let mut changed = false; + for key in keys { + changed |= object.remove(*key).is_some(); + } + Ok(changed) +} + /// Persistence is exercised against real files in a temp data dir, not against /// in-memory conversions — the failures these guard against (ghosted fields, /// migration loss, write amplification) only appear on the round-trip through @@ -1609,6 +1718,107 @@ mod tests { tmp } + #[test] + #[serial] + fn agent_reset_removes_only_owned_json_fields_and_preserves_unknowns() { + let _tmp = setup_isolated_data_dir(); + let path = UserSettings::settings_path(); + let seeded = serde_json::json!({ + "schema_version": 3, + "interaction": { + "agent_enter_sends": true, + "auto_paste_enabled": false, + "future_interaction": "keep" + }, + "speech": { + "language": "pl", + "assistive": { + "llm_endpoint": "https://agent.example", + "llm_model": "agent-model", + "provider": "openai-responses" + }, + "formatting": { "level": "smart" }, + "future_speech": { "keep": true } + }, + "system": { + "agent_workspace_roots": ["/tmp/project"], + "openai_oauth_client_id": "openai-client", + "anthropic_oauth_client_id": "anthropic-client", + "xai_oauth_client_id": "xai-client", + "onboarding_mode": "basic", + "future_system": 42 + }, + "agent": { + "permissions": null, + "capabilities": null, + "future_agent": "keep" + }, + "future_top_level": { "keep": "exactly" } + }); + fs::write( + &path, + serde_json::to_string_pretty(&seeded).expect("serialize fixture"), + ) + .expect("seed settings"); + + UserSettings::remove_agent_owned_state().expect("surgical Agent settings reset"); + + let after: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).expect("read reset settings")) + .expect("parse reset settings"); + for pointer in [ + "/interaction/agent_enter_sends", + "/speech/assistive/llm_endpoint", + "/speech/assistive/llm_model", + "/speech/assistive/provider", + "/system/agent_workspace_roots", + "/system/openai_oauth_client_id", + "/system/anthropic_oauth_client_id", + "/system/xai_oauth_client_id", + "/agent/permissions", + "/agent/capabilities", + ] { + assert!( + after.pointer(pointer).is_none(), + "Agent field survived: {pointer}" + ); + } + for pointer in [ + "/interaction/auto_paste_enabled", + "/interaction/future_interaction", + "/speech/language", + "/speech/formatting", + "/speech/future_speech", + "/system/onboarding_mode", + "/system/future_system", + "/agent/future_agent", + "/future_top_level", + ] { + assert_eq!( + after.pointer(pointer), + seeded.pointer(pointer), + "non-Agent field changed: {pointer}" + ); + } + } + + #[test] + #[serial] + fn agent_reset_refuses_malformed_settings_without_rewriting_bytes() { + let _tmp = setup_isolated_data_dir(); + let path = UserSettings::settings_path(); + let malformed = b"{ this is not valid settings JSON"; + fs::write(&path, malformed).expect("seed malformed settings"); + + UserSettings::remove_agent_owned_state().expect_err("malformed settings must fail closed"); + + assert_eq!( + fs::read(&path).expect("read malformed settings after refusal"), + malformed, + "Agent reset rewrote malformed settings" + ); + } + /// Zoom normalization: clamped to the supported range, rounded to two /// decimals, and the effective default encoded as `None` so it is omitted /// from the file entirely. diff --git a/core/embedder/engine.rs b/core/embedder/engine.rs index f230314b..b58e4853 100644 --- a/core/embedder/engine.rs +++ b/core/embedder/engine.rs @@ -1,7 +1,7 @@ //! Embedder Engine - offline MiniLM embeddings via Candle BERT. //! -//! Provides text embeddings using a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model (fp16). -//! No runtime downloads; model must be embedded or present on disk. +//! Provides text embeddings using a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model. +//! No runtime downloads; model must be embedded, bundled in the app, or present in the HF cache. use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -366,7 +366,8 @@ impl EmbedderEngine { } /// Locate a model directory: explicit path, then `CODESCRIBE_EMBEDDER_PATH`, -/// then an HF cache snapshot for the configured or default repo. +/// then the signed app's runtime resource, then an HF cache snapshot for the +/// configured or default repo. /// /// Never downloads. Failure returns an error naming the exact commands and env /// vars that would fix it. @@ -382,6 +383,15 @@ fn resolve_model_path(explicit: Option<&PathBuf>, repo_override: Option<&str>) - } } + // A public app carries MiniLM as a normal signed resource rather than + // compiling 471 MB through every Cargo target. An explicit repo override + // still wins by bypassing this default-model resource. + if repo_override.is_none() + && let Some(bundled) = bundled_app_model_path() + { + return Ok(bundled); + } + if let Some(repo) = repo_override { if let Some(snapshot) = hf_cache::find_snapshot_with_any( repo, @@ -405,6 +415,20 @@ fn resolve_model_path(explicit: Option<&PathBuf>, repo_override: Option<&str>) - )) } +/// Resolve `Codescribe.app/Contents/Resources/models/embedder` without assuming +/// a fixed install location. CLI/test binaries naturally return `None` and fall +/// through to the HF cache. +fn bundled_app_model_path() -> Option { + let executable = std::env::current_exe().ok()?; + bundled_model_path_for_executable(&executable) +} + +fn bundled_model_path_for_executable(executable: &Path) -> Option { + let macos_dir = executable.parent()?; + let candidate = macos_dir.join("../Resources/models/embedder"); + model_files_present(&candidate).then(|| candidate.canonicalize().unwrap_or(candidate)) +} + /// Whether `path` holds a usable model: both config files plus safetensors or /// ONNX weights. /// @@ -593,4 +617,23 @@ mod tests { let sim = EmbedderEngine::similarity(&a, &b); assert!(sim.abs() < 0.001); } + + #[test] + fn resolves_signed_app_embedder_resource_from_executable_geometry() { + let temp = tempfile::tempdir().unwrap(); + let contents = temp.path().join("Codescribe.app/Contents"); + let executable = contents.join("MacOS/Codescribe"); + let model = contents.join("Resources/models/embedder"); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&model).unwrap(); + std::fs::write(&executable, b"").unwrap(); + for name in ["config.json", "tokenizer.json", "model.safetensors"] { + std::fs::write(model.join(name), b"fixture").unwrap(); + } + + assert_eq!( + bundled_model_path_for_executable(&executable), + Some(model.canonicalize().unwrap()) + ); + } } diff --git a/core/embedder/mod.rs b/core/embedder/mod.rs index fc0106e7..8e908185 100644 --- a/core/embedder/mod.rs +++ b/core/embedder/mod.rs @@ -1,7 +1,7 @@ //! Text Embedder module - semantic embeddings using MiniLM (offline). //! //! Provides semantic text embeddings for RAG, similarity search, and context matching. -//! Uses a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model (no runtime downloads by default). +//! Uses a bundled, cached, or explicitly embedded paraphrase-multilingual-MiniLM-L12-v2 model (no runtime downloads). //! Override with `CODESCRIBE_EMBEDDER_REPO=sentence-transformers/...` (HF cache). //! //! ## Quick Start diff --git a/core/lib.rs b/core/lib.rs index 822381da..8f0aae03 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -176,5 +176,5 @@ pub use config::{get_assistive_prompt_path, get_formatting_prompt_path, reset_to pub use llm::{ai_formatting, client}; pub use pipeline::contracts; pub use pipeline::stream_postprocess; -pub use quality::{overlay_quality, qube_daemon, qube_report}; +pub use quality::{engine_contract, overlay_quality, qube_daemon, qube_report}; pub use util::{safe_path, status}; diff --git a/core/mcp/config_store.rs b/core/mcp/config_store.rs index 4a538950..34c3faae 100644 --- a/core/mcp/config_store.rs +++ b/core/mcp/config_store.rs @@ -64,7 +64,7 @@ pub fn list_servers() -> Result> { /// Path-explicit twin of [`list_servers`]. Every public entry point in this /// module delegates to an `_at` variant so the tests can drive the real logic /// against a temp dir instead of the operator's live `~/.codescribe/mcp.json`. -fn list_servers_at(path: &Path) -> Result> { +pub fn list_servers_at(path: &Path) -> Result> { let Some(config) = McpConfigFile::load_optional(path)? else { return Ok(Vec::new()); }; diff --git a/core/mcp/mod.rs b/core/mcp/mod.rs index 3e4c0643..9853d23a 100644 --- a/core/mcp/mod.rs +++ b/core/mcp/mod.rs @@ -23,7 +23,7 @@ pub use client::{ default_mcp_config_path, }; pub use config_store::{ - McpProbeSummary, McpServerSpec, McpServerSummary, add_server, list_servers, + McpProbeSummary, McpServerSpec, McpServerSummary, add_server, list_servers, list_servers_at, probe_server_blocking, remove_server, test_server_blocking, update_server, }; pub use secret_migration::{ diff --git a/core/quality/engine_contract.rs b/core/quality/engine_contract.rs new file mode 100644 index 00000000..04a79f6c --- /dev/null +++ b/core/quality/engine_contract.rs @@ -0,0 +1,462 @@ +//! Locked THE ENGINE contract for quality-report HTML and corpus JSON. +//! +//! This module exists so an agent cannot re-invent sealed/committed on every +//! session. The bars, the relay, the forbidden operations, and the product +//! goal are compile-time constants. Quality HTML embeds them. Corpus schema +//! v3 names them. Tests fail if anyone "simplifies" the doctrine back to +//! "the whole text is mutable until session seal". + +use serde::{Deserialize, Serialize}; + +/// Schema id carried by every `codescribe-corpus` report that honours this lock. +pub const CORPUS_REPORT_SCHEMA: &str = "codescribe-corpus-parity/v3"; + +/// Stable id of the engine contract itself. +pub const ENGINE_CONTRACT_ID: &str = "the-engine/v1"; + +/// Path of the agent-facing prose lock, relative to the repo root. +pub const ENGINE_CONTRACT_DOC: &str = "docs/THE_ENGINE_CONTRACT.md"; + +/// What a quality report is allowed to treat as the document vs a proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReportSurfaceRole { + /// Live Apple hypothesis for an open or just-committed span. + LiveHypothesis, + /// Layer-1 hole-fill inside a still-unsealed span. + WhisperHoleFill, + /// Closed span after Apple + Whisper + lexicon fusion. + SealedSpan, + /// Session document after `transcript_sealed`. + SessionDocument, + /// Full-file HQ or Cloud pass after session seal — never auto-applied. + HumanTriggeredProposal, +} + +/// One of the three finality bars. Not synonyms. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FinalityBar { + /// This layer finished its hypothesis for the fragment. The layer is + /// banned from further overwrite of that span. Preview stays grey; + /// committed is bright. This is not the document. + UtteranceFinal, + /// Apple + Whisper + lexicon finished fusion for a Silero-bounded span. + /// The record `[sample_start, sample_end)` becomes append-only and may + /// start inline formatting. Order on the PCM axis is frozen. + UtteranceSealed, + /// The whole session — tail and formatter included — was assembled into + /// the document. Automation puts its hands down. Full HQ / Cloud may + /// only propose a variant. + TranscriptSealed, +} + +/// A layer in the live relay. Ban is per-layer, per-span: the layer that +/// already passed this span is out; the next one may enrich the same time +/// window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayLayer { + Apple, + Whisper, + Lexicon, + Formatter, + Human, +} + +/// Machine-readable lock. Quality HTML and corpus JSON must serialize this +/// object, not a free-form paragraph an agent can paraphrase. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EngineContract { + pub id: &'static str, + pub primary_key: &'static str, + pub relay: &'static [RelayLayer], + pub bars: &'static [FinalityBar], + pub forbidden: &'static [&'static str], + pub whisper_window: &'static str, + pub full_file_pass: &'static str, + pub product_goal: &'static str, +} + +/// The only contract instance quality reports may emit. +pub const ENGINE_CONTRACT: EngineContract = EngineContract { + id: ENGINE_CONTRACT_ID, + primary_key: "pcm_time", + relay: &[ + RelayLayer::Apple, + RelayLayer::Whisper, + RelayLayer::Lexicon, + RelayLayer::Formatter, + RelayLayer::Human, + ], + bars: &[ + FinalityBar::UtteranceFinal, + FinalityBar::UtteranceSealed, + FinalityBar::TranscriptSealed, + ], + forbidden: &[ + "rewrite_from_zero", + "reorder_spans", + "hallucinate_into_silence", + "full_file_in_automatic_pipeline", + "auto_replace_after_transcript_sealed", + "treat_committed_as_document", + "treat_whole_text_mutable_until_session_seal", + ], + whisper_window: "3-5s utterance-bounded partials", + full_file_pass: "button_only_proposal", + product_goal: "energy × time → the true sentence, live in the buffer, ~10ms to paste", +}; + +/// Required visual of a private quality HTML. WER is a footnote. +pub const QUALITY_REPORT_SURFACE: &str = "seal-atlas"; + +/// Gold take checked into the repo — the report an agent must not replace +/// with a scores table. +pub const SEAL_ATLAS_GOLD_HTML: &str = "docs/quality-reports/seal-atlas.take01.html"; + +/// Speech faster than this (characters / second over a span range) is a +/// clock-lie: the range is not the range of that speech. Take 01 span 2 +/// is 410 chars/s. Conversational Polish sits well below 20. +pub const CLOCK_LIE_CHARS_PER_SEC: f32 = 30.0; + +/// How a sealed span's word payload is allowed to be read. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpanGrain { + /// SFSpeech returned more than one distinct word pin. + Word, + /// One segment covering the Apple commit-to-commit window. + Utterance, +} + +/// Classify grain. Per-word pins are real where they exist and never +/// guaranteed — two or more distinct ranges = word-grain, otherwise utterance. +pub fn span_grain(distinct_word_ranges: usize) -> SpanGrain { + if distinct_word_ranges >= 2 { + SpanGrain::Word + } else { + SpanGrain::Utterance + } +} + +/// Clock-lie: too many characters for the claimed PCM duration. +pub fn is_clock_lie(chars: usize, duration_secs: f32) -> bool { + duration_secs > 0.0 && (chars as f32 / duration_secs) > CLOCK_LIE_CHARS_PER_SEC +} + +/// Grapheme ticks inside a word range are an even split, never a measurement. +pub const LETTER_TIMING: &str = "interpolation_not_measurement"; + +/// Directory Voice Lab scans. Corpus atlas HTML must land here (or under +/// `$CODESCRIBE_ARTIFACTS_DIR`) or the operator never sees it. +pub const VOICE_LAB_ARTIFACTS_ROOT: &str = "~/.vibecrafted/artifacts/vetcoders/codescribe"; + +/// How Voice Lab labels a discovered HTML. Mirrors `discover_quality_reports` +/// in voice-lab `server.py` — change both or the catalog lies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoiceLabReportKind { + SealAtlas, + QualityContract, + QualityReport, +} + +impl VoiceLabReportKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::SealAtlas => "seal_atlas", + Self::QualityContract => "quality_contract", + Self::QualityReport => "quality_report", + } + } +} + +/// Same classifier Voice Lab uses on `title + relative path`. +pub fn voice_lab_kind(title: &str, relative_path: &str) -> VoiceLabReportKind { + let lowered = format!("{title} {relative_path}").to_ascii_lowercase(); + if lowered.contains("seal atlas") || lowered.contains("seal-atlas") { + VoiceLabReportKind::SealAtlas + } else if lowered.contains("quality") && lowered.contains("contract") { + VoiceLabReportKind::QualityContract + } else { + VoiceLabReportKind::QualityReport + } +} + +/// Role of a named quality-report column. WER against a column does not +/// promote that column to document. +pub fn surface_role(column: &str) -> Option { + match column { + "raw" | "live" => Some(ReportSurfaceRole::LiveHypothesis), + "post" | "layer1" => Some(ReportSurfaceRole::WhisperHoleFill), + "sealed" => Some(ReportSurfaceRole::SealedSpan), + "delivered" | "session" => Some(ReportSurfaceRole::SessionDocument), + "ai" | "ai_formatted" | "cloud" | "hq" => Some(ReportSurfaceRole::HumanTriggeredProposal), + _ => None, + } +} + +/// Self-contained HTML plate. Inlined into Qube / corpus / teacher reports +/// so opening any quality HTML shows the lock before the scores. +pub fn render_engine_contract_html() -> String { + let bars = [ + ( + "utterance_final / committed", + "This layer finished its hypothesis for the fragment. That layer is banned from further overwrite of this span. Preview grey, committed bright. Not the document.", + ), + ( + "utterance_sealed", + "Apple + Whisper + lexicon finished fusion for the Silero-bounded span. Record [sample_start, sample_end) is append-only and may start inline formatting. Order on the PCM axis is frozen.", + ), + ( + "transcript_sealed", + "The session — tail and formatter included — was assembled into the document. Automation puts its hands down. Full HQ / Cloud may only propose a variant.", + ), + ]; + let mut rows = String::new(); + for (name, meaning) in bars { + rows.push_str(&format!("{name}{meaning}\n")); + } + let forbidden = ENGINE_CONTRACT + .forbidden + .iter() + .map(|item| format!("
  • {item}
  • ")) + .collect::>() + .join(""); + format!( + r#"
    +

    THE ENGINE · quality-report contract · {id}

    +

    Place on the canvas is given by energy in time — not by tokens.

    +

    {goal}

    +

    Relay: Apple → Whisper → lexicon → formatter → human. Ban is per layer, per span. Whisper works 3–5 s partials at utterance boundaries and fills holes. It does not hallucinate into silence and does not see full audio unless a human presses the button.

    + + + +{rows} + +
    BarMeans
    +

    Before transcript_sealed the whole document is not mutable. Closed spans stay on the PCM axis. The tail may still evolve. Whisper may replace weaker evidence inside a still-unsealed span. Stop closes only the tail.

    +
      {forbidden}
    +
    +"#, + id = ENGINE_CONTRACT.id, + key = ENGINE_CONTRACT.primary_key, + goal = ENGINE_CONTRACT.product_goal, + rows = rows, + forbidden = forbidden, + ) +} + +/// CSS for the plate. Safe on the light Qube page and the dark teacher page. +pub fn engine_contract_css() -> &'static str { + r#" +.engine-contract { border: 1px solid #1f2937; border-radius: 12px; padding: 16px 18px; margin: 16px 0 20px; background: #111827; color: #e5e7eb; } +.engine-contract-kicker { font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: #93c5fd; margin: 0 0 8px; } +.engine-contract h2 { font-size: 1.05rem; margin: 0 0 8px; color: #fff; } +.engine-contract-goal { font-size: 0.95rem; color: #fde68a; margin: 0 0 10px; } +.engine-contract-relay, .engine-contract-not { font-size: 0.88rem; color: #d1d5db; margin: 0 0 10px; } +.engine-contract-bars { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin: 0 0 10px; } +.engine-contract-bars th, .engine-contract-bars td { border-bottom: 1px solid #374151; padding: 6px 8px; text-align: left; vertical-align: top; } +.engine-contract-bars th { width: 28%; color: #93c5fd; } +.engine-contract-forbidden { margin: 0; padding-left: 1.2rem; font-size: 0.82rem; color: #fca5a5; } +.engine-contract-forbidden code { color: #fecaca; } +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_id_and_schema_are_stable() { + assert_eq!(ENGINE_CONTRACT.id, "the-engine/v1"); + assert_eq!(CORPUS_REPORT_SCHEMA, "codescribe-corpus-parity/v3"); + assert_eq!(ENGINE_CONTRACT.primary_key, "pcm_time"); + } + + #[test] + fn three_bars_in_order_and_not_synonyms() { + assert_eq!(ENGINE_CONTRACT.bars.len(), 3); + assert_eq!(ENGINE_CONTRACT.bars[0], FinalityBar::UtteranceFinal); + assert_eq!(ENGINE_CONTRACT.bars[1], FinalityBar::UtteranceSealed); + assert_eq!(ENGINE_CONTRACT.bars[2], FinalityBar::TranscriptSealed); + } + + #[test] + fn relay_is_apple_then_whisper_then_lexicon_then_formatter_then_human() { + assert_eq!( + ENGINE_CONTRACT.relay, + &[ + RelayLayer::Apple, + RelayLayer::Whisper, + RelayLayer::Lexicon, + RelayLayer::Formatter, + RelayLayer::Human + ] + ); + } + + #[test] + fn whole_text_mutable_until_seal_is_explicitly_forbidden() { + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"treat_whole_text_mutable_until_session_seal") + ); + assert!(ENGINE_CONTRACT.forbidden.contains(&"rewrite_from_zero")); + assert!(ENGINE_CONTRACT.forbidden.contains(&"reorder_spans")); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"hallucinate_into_silence") + ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"full_file_in_automatic_pipeline") + ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"auto_replace_after_transcript_sealed") + ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"treat_committed_as_document") + ); + } + + #[test] + fn hq_and_cloud_are_proposals_not_documents() { + assert_eq!( + surface_role("cloud"), + Some(ReportSurfaceRole::HumanTriggeredProposal) + ); + assert_eq!( + surface_role("hq"), + Some(ReportSurfaceRole::HumanTriggeredProposal) + ); + assert_eq!( + surface_role("ai_formatted"), + Some(ReportSurfaceRole::HumanTriggeredProposal) + ); + assert_eq!( + surface_role("delivered"), + Some(ReportSurfaceRole::SessionDocument) + ); + assert_ne!( + surface_role("raw"), + Some(ReportSurfaceRole::SessionDocument) + ); + } + + #[test] + fn html_plate_names_every_bar_and_the_pcm_key() { + let html = render_engine_contract_html(); + assert!(html.contains("data-contract=\"the-engine/v1\"")); + assert!(html.contains("data-primary-key=\"pcm_time\"")); + assert!(html.contains("utterance_final / committed")); + assert!(html.contains("utterance_sealed")); + assert!(html.contains("transcript_sealed")); + assert!(html.contains("treat_whole_text_mutable_until_session_seal")); + assert!( + !html.contains("the whole text is mutable"), + "the rejected one-liner must not re-enter the plate" + ); + } + + #[test] + fn canonical_doc_exists_and_matches_the_lock() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let path = root.join(ENGINE_CONTRACT_DOC); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("{} must exist: {err}", path.display())); + for needle in [ + "the-engine/v1", + "pcm_time", + "utterance_final", + "utterance_sealed", + "transcript_sealed", + "treat_whole_text_mutable_until_session_seal", + "rewrite_from_zero", + "button_only_proposal", + "Apple → Whisper → lexicon → formatter → human", + "seal-atlas", + "SealedSpan.words", + "clock-lie", + "interpolation", + "Voice Lab", + "seal_atlas", + ] { + assert!( + body.contains(needle), + "{ENGINE_CONTRACT_DOC} missing locked token {needle:?}" + ); + } + assert!( + !body.contains("cały tekst jest mutable"), + "the rejected sentence must not live in the canonical doc" + ); + } + + #[test] + fn full_file_pass_is_never_automatic() { + assert_eq!(ENGINE_CONTRACT.full_file_pass, "button_only_proposal"); + assert!(ENGINE_CONTRACT.whisper_window.contains("3-5s")); + } + + #[test] + fn take01_span2_is_the_canonical_clock_lie() { + assert!(is_clock_lie(41, 0.10)); + assert!(!is_clock_lie(6, 0.24)); // "ten" @ 240 ms + assert_eq!(span_grain(6), SpanGrain::Word); + assert_eq!(span_grain(1), SpanGrain::Utterance); + assert_eq!(LETTER_TIMING, "interpolation_not_measurement"); + assert_eq!(QUALITY_REPORT_SURFACE, "seal-atlas"); + } + + #[test] + fn gold_atlas_html_is_a_pcm_instrument_not_a_wer_table() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let path = root.join(SEAL_ATLAS_GOLD_HTML); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("{} must exist: {err}", path.display())); + for needle in [ + "Seal Atlas", + "SealedSpan.words", + "kłamstwo zegarowe", + "word-grain", + "utterance-grain", + "równomierna interpolacja", + "CODESCRIBE_SEAL_ATLAS_DUMP", + "vad_atlas_probe", + "whisper_words", + ] { + assert!( + body.contains(needle), + "{SEAL_ATLAS_GOLD_HTML} missing {needle:?}" + ); + } + assert!( + !body.contains("Avg WER"), + "gold atlas must not be a scores table" + ); + assert!(body.contains(r#"class="stat""#)); + assert_eq!( + voice_lab_kind( + "Seal Atlas — take 01", + "quality-reports/seal-atlas.take01.html" + ), + VoiceLabReportKind::SealAtlas + ); + assert_eq!( + voice_lab_kind("Codescribe Quality Report", "quality/apple-layer0.html"), + VoiceLabReportKind::QualityReport + ); + assert_eq!( + voice_lab_kind("THE ENGINE quality-report contract", "docs/contract.html"), + VoiceLabReportKind::QualityContract + ); + } +} diff --git a/core/quality/mod.rs b/core/quality/mod.rs index c02cc1ae..6bcf2c0d 100644 --- a/core/quality/mod.rs +++ b/core/quality/mod.rs @@ -1,7 +1,9 @@ //! Quality surfaces — where transcription truth is measured, corrected, and learned from. //! -//! Four independent loops share this facade: +//! Five independent loops share this facade: //! +//! - `engine_contract` — locked THE ENGINE bars / relay / forbidden ops that +//! every quality HTML and `codescribe-corpus` v3 report must carry. //! - `overlay_quality` — captures human edits of the overlay FINAL transcript and //! distils them into custom lexicon rules (the live, per-user loop). //! - `qube_report` — batch WAV evaluation: transcribe, format, score, emit artifacts. @@ -10,9 +12,11 @@ //! - `teacher` — the offline learning triangle (Apple live × Whisper × human reference) //! that produces merged deliveries and attention spans. //! -//! Only `teacher` is re-exported here; the other three are reached through their -//! own module paths. +//! Only `teacher` and `engine_contract` are re-exported here; the others are +//! reached through their own module paths. +/// Locked THE ENGINE contract for quality-report HTML and corpus JSON. +pub mod engine_contract; pub mod overlay_quality; /// Background Qube donor daemon: opt-in stop-path WAV/transcript persistence. pub mod qube_daemon; @@ -21,6 +25,10 @@ pub mod qube_report; /// Teacher loop: attention flags, lexicon feedback, polygon token helpers. pub mod teacher; +pub use engine_contract::{ + CORPUS_REPORT_SCHEMA, ENGINE_CONTRACT, ENGINE_CONTRACT_ID, EngineContract, + render_engine_contract_html, +}; pub use teacher::{ Layer1MergeMode, Layer1MergedDelivery, MergeMode, MergedDelivery, TeacherInput, TeacherReport, merge_live_layer1, merge_live_whisper, merge_live_whisper_with_terms, report_to_html, teach, diff --git a/core/quality/qube_report.rs b/core/quality/qube_report.rs index f02d70b0..868d6381 100644 --- a/core/quality/qube_report.rs +++ b/core/quality/qube_report.rs @@ -888,12 +888,19 @@ fn render_markdown(report: &QualityReport) -> String { /// stays portable. Reference transcripts are emitted but hidden unless /// `debug_mode` is set — the operator is meant to judge the audio first, not read /// the answer. Every interpolated value goes through [`html_escape`]. -fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { +/// Render the reusable, self-contained Qube review surface. +/// +/// Production replay tools call this renderer with an in-memory report so the +/// operator gets the same audio controls, keyboard navigation and annotation +/// workflow without routing the recording through Qube's legacy LocalWhisper +/// runner. +pub fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { let debug = config.debug_mode; let mut body = String::new(); body.push_str(&format!( - "

    Codescribe Quality Report

    Generated: {}

    Metrics reference: {}

    Raw semantics: text_committed={} • quality_gate_dropped={} • no_speech_detected={}

    ", + "

    Codescribe Quality Report

    {}

    Generated: {}

    Metrics reference: {}

    Raw semantics: text_committed={} • quality_gate_dropped={} • no_speech_detected={}

    ", + crate::quality::engine_contract::render_engine_contract_html(), html_escape(&report.generated_at), html_escape(&report.environment.metrics_reference), report.summary.raw_text_committed, @@ -1022,11 +1029,16 @@ fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { ); render_ref_section( &mut body, - "AI formatted (candidate)", + "AI formatted (proposal — not the document)", t.ai_formatted.as_deref(), debug, ); - render_ref_section(&mut body, "Cloud reference", t.cloud.as_deref(), debug); + render_ref_section( + &mut body, + "Cloud (proposal — not the document)", + t.cloud.as_deref(), + debug, + ); render_ref_section( &mut body, "Corpus reference (.txt)", @@ -1047,6 +1059,7 @@ fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { } let debug_flag = if debug { "true" } else { "false" }; + let contract_css = crate::quality::engine_contract::engine_contract_css(); format!( r#" @@ -1083,6 +1096,7 @@ audio {{ width: 100%; margin: 8px 0; }} .ref pre {{ background: #f6f6f6; padding: 10px; border-radius: 6px; white-space: pre-wrap; }} .ref.hidden {{ display: none; }} .errors {{ margin-top: 10px; color: #a00; }} +{contract_css} diff --git a/core/quality/teacher/report.rs b/core/quality/teacher/report.rs index 4777e54d..fc2450a2 100644 --- a/core/quality/teacher/report.rs +++ b/core/quality/teacher/report.rs @@ -338,8 +338,10 @@ pub fn report_to_html(report: &TeacherReport) -> String { th {{ background: #1c1f26; text-align: left; }} code {{ color: #fbbf24; }} .meta {{ color: #9aa0a6; font-size: 0.85rem; }} + {contract_css}

    Codescribe Teacher

    +{contract}

    label: {label} · live tokens: {live_n} · whisper tokens: {wh_n} · equal ops: {eq} · whisper errors: {we} · at live-weak: {wl} · hit-rate: {hit}

    Thesis: {thesis}
    @@ -362,6 +364,8 @@ equal ops: {eq} · whisper errors: {we} · at live-weak: {wl} · hit-rate: {hit} hit = hit, thesis = html_escape(&report.thesis_summary), rows = rows, + contract = crate::quality::engine_contract::render_engine_contract_html(), + contract_css = crate::quality::engine_contract::engine_contract_css(), lex = if lex.is_empty() { "
  • none
  • ".into() } else { diff --git a/core/stt/apple_stt/mod.rs b/core/stt/apple_stt/mod.rs index b1985dee..3fde529a 100644 --- a/core/stt/apple_stt/mod.rs +++ b/core/stt/apple_stt/mod.rs @@ -351,6 +351,38 @@ fn init_impl() -> Result<()> { Ok(()) } +/// Fail-closed readiness probe for unattended tools that must never raise a +/// Speech Recognition dialog or install Apple speech assets. +/// +/// This deliberately bypasses the cached [`init`] path: `probe` with +/// `allow_download=false` is observational, while `init` is allowed to request +/// SFSpeech authorization and download SpeechTranscriber assets. Callers may +/// proceed only when the selected backend is already installed and either does +/// not require Speech TCC or is already authorized. +pub fn ensure_noninteractive_ready(language: Option<&str>) -> Result<()> { + ensure_supported_platform()?; + let locale = resolved_locale(language); + let probe = probe_bridge(&locale, false)?; + if !probe.supported { + bail!("Apple on-device STT does not support locale '{locale}' without changing host state"); + } + if !probe.installed { + bail!( + "Apple on-device STT assets for locale '{locale}' are not installed; unattended replay refuses to download them" + ); + } + match speech_auth_init_decision(probe.backend, probe.speech_auth.as_deref()) { + SpeechAuthInitDecision::NotRequired | SpeechAuthInitDecision::Proceed => Ok(()), + SpeechAuthInitDecision::RequestAuthorization => bail!( + "Speech Recognition permission is not determined; unattended replay refuses to request it" + ), + SpeechAuthInitDecision::HardFail => { + let auth = probe.speech_auth.as_deref().unwrap_or("unknown"); + bail!("Speech Recognition permission is {auth}; unattended replay will not change it") + } + } +} + /// Runtime availability guard used by engine router. pub(crate) fn is_runtime_available() -> bool { if !cfg!(target_os = "macos") { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a52c16b4..73024f6d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -306,10 +306,10 @@ The Rust AppKit `ui/voice_chat/` module (`mod.rs` / `api.rs` / `handlers.rs` / ` ## Model Location -**Current runtime truth**: Whisper is embedded by default when the model snapshot is -available at build time. Runtime lookup remains available as a fallback when -embedding is disabled with `CODESCRIBE_NO_EMBED=1` or the build cannot embed the -model: +**Current runtime truth**: daily builds keep Whisper and MiniLM weights out of +Cargo artifacts. MiniLM loads from the signed app resource (or HF cache for CLI +and development); Whisper resolves from the paths below. Explicit fat builds +may still opt into binary embedding: 1. `CODESCRIBE_MODEL_PATH` environment variable 2. `~/.codescribe/models/whisper-large-v3-turbo/` (fp16 default) @@ -317,6 +317,11 @@ model: 4. Legacy fallback: `whisper-large-v3-turbo-mlx-q8` dir or `LibraxisAI/whisper-large-v3-turbo-mlx-q8` snapshots +MiniLM resolution: `CODESCRIBE_EMBEDDER_PATH`, then +`Codescribe.app/Contents/Resources/models/embedder`, then the configured/default +Hugging Face cache snapshot. `CODESCRIBE_EMBED_EMBEDDER=1` is the explicit +binary-embed escape hatch. + ## Related Documentation - [`guide/README.md`](guide/README.md) — User documentation diff --git a/docs/ENV_REGISTRY.toml b/docs/ENV_REGISTRY.toml index 0a9d416b..dd2fe952 100644 --- a/docs/ENV_REGISTRY.toml +++ b/docs/ENV_REGISTRY.toml @@ -912,11 +912,11 @@ category = "embedder" description = "Override path to E5 embedder model directory" [vars.CODESCRIBE_EMBEDDER_REPO] -default = "intfloat/multilingual-e5-large" +default = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" type = "string" reload = "rebuild" category = "embedder" -description = "HF repo for E5 embedder (used for embedding at build/runtime)" +description = "HF repo for the MiniLM semantic-gate model (runtime cache resolution and optional build-time embedding)" [vars.CODESCRIBE_EMBEDDER_DEVICE] default = "" @@ -1450,7 +1450,7 @@ default = "0" type = "bool" reload = "rebuild" category = "build" -description = "Skip embedding Whisper model in binary" +description = "Skip every optional model embed in Rust artifacts; Silero remains embedded" [vars.CODESCRIBE_EMBED_WHISPER] default = "0" @@ -1459,6 +1459,13 @@ reload = "rebuild" category = "build" description = "Opt-in: embed Whisper model in binary (distribution builds); default loads from HF cache at runtime" +[vars.CODESCRIBE_EMBED_EMBEDDER] +default = "0" +type = "bool" +reload = "rebuild" +category = "build" +description = "Opt-in fat/debug path: compile MiniLM into Rust artifacts; normal app builds package it as a signed runtime resource" + [vars.CODESCRIBE_EMBED_TTS] default = "0" type = "bool" diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 6ebeb89a..67669774 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -52,7 +52,7 @@ make notarize # Notarize with Apple (requires Developer ID) # make release-dmgs # Build + sign + notarize standard and full DMGs ``` -**Result**: standard `Codescribe_X.Y.Z-….dmg` (slim: Silero + MiniLM; Whisper via Settings download / cache) and optional `…_full.dmg` (embeds Whisper too). Daily operator flow is slim: `make release && make dmg-signed && make notarize`. +**Result**: standard `Codescribe_X.Y.Z-….dmg` (slim: Silero embedded, MiniLM signed as a runtime resource, Whisper via Settings download / cache) and optional `…_full.dmg` (embeds Whisper too). Daily operator flow is slim: `make release && make dmg-signed && make notarize`. ### About panel commit stamp diff --git a/docs/TEAM_SETUP.md b/docs/TEAM_SETUP.md index 5ea43f0e..d624958a 100644 --- a/docs/TEAM_SETUP.md +++ b/docs/TEAM_SETUP.md @@ -59,10 +59,11 @@ Grant in: System Settings > Privacy & Security ## Model -**Embedded-first Whisper policy**: `whisper-large-v3-turbo` (mlx-community fp16; legacy q8 fallback) -**Embedded Embedder**: `paraphrase-multilingual-MiniLM-L12-v2` (for semantic gating) +**Runtime Whisper policy**: `whisper-large-v3-turbo` (mlx-community fp16; legacy q8 fallback) +**Runtime Embedder**: `paraphrase-multilingual-MiniLM-L12-v2` (signed app resource or HF cache, for semantic gating) -- `core/build.rs` embeds Whisper by default when a complete model is available at build time. +- `core/build.rs` keeps Whisper and MiniLM out of normal Cargo artifacts; explicit + `CODESCRIBE_EMBED_WHISPER=1` / `CODESCRIBE_EMBED_EMBEDDER=1` builds are fat debug/offline paths. - Runtime fallback resolves Whisper from exactly one shared contract in `core/config/models.rs`: `CODESCRIBE_MODEL_PATH` → configured local model path/alias → configured HF repo snapshot → default local turbo model → default HF cache snapshot. diff --git a/docs/THE_ENGINE_CONTRACT.md b/docs/THE_ENGINE_CONTRACT.md new file mode 100644 index 00000000..f22cc85a --- /dev/null +++ b/docs/THE_ENGINE_CONTRACT.md @@ -0,0 +1,122 @@ +# THE ENGINE — quality-report contract + +| | | +| --- | --- | +| id | `the-engine/v1` | +| corpus schema | `codescribe-corpus-parity/v3` | +| primary key | `pcm_time` | +| source of truth | `core/quality/engine_contract.rs` | +| surfaces | **Seal Atlas** in Voice Lab (`voice-lab` tab); gold HTML `docs/quality-reports/seal-atlas.take01.html` | + +Do not re-derive this. If a sentence here disagrees with `ENGINE_CONTRACT` in Rust, the Rust constant wins and this file is wrong. + +## Product goal + +Place on the canvas is given by **energy in time** — mechanical waves from the speaker's vocal cords, while they speak. Not by tokens. + +Four live layers, everything in the buffer, **~10 ms to paste**: + +> energy × time → the true sentence, live in the buffer, ~10ms to paste + +Preview, colours, successive hypotheses and seals are internal mechanics. The user buys the sentence, immediately, ready to paste. 20 seconds of delay kills even a perfect transcript: it is no longer presence. + +## Relay + +Apple → Whisper → lexicon → formatter → human + +This is a band, not a queue of correctors. Ban is **per layer, per span**. The layer that already passed this span is out. The next one may enrich the **same** time window. + +- **Apple** draws now (thin, sharp pencil). Span commits → Apple out. +- **Whisper** enters the buffer on **3-5s utterance-bounded partials**. Never full audio in the automatic pipeline (`full_file_pass = button_only_proposal`). Must not hallucinate into silence. Excess recall is stuffed into holes Apple left (`ReplaceRange`, never full-replace). +- **Lexicon / Light+** tune after Whisper settles. +- **Formatter** (Responses, `previous_response_id`) has a trash bucket. It may throw away. It may not rearrange the plate. +- **Human** is last, after seal. + +`NEVER REWRITE FROM ZERO.` Append-only. Key = PCM sample counter, not token position. + +## Three bars — not synonyms + +| Bar | Means | +| --- | --- | +| `utterance_final` / committed | This layer finished its hypothesis for the fragment. That layer is banned from further overwrite of this span. Preview grey, committed bright. **Not the document.** | +| `utterance_sealed` | Apple + Whisper + lexicon finished fusion for the Silero-bounded span. Record `[sample_start, sample_end)` becomes append-only and may start inline formatting. Order on the PCM axis is frozen. | +| `transcript_sealed` | The whole session — tail and formatter included — was assembled into the document. Automation puts its hands down. Full HQ / Cloud may only propose a variant. | + +`committed` does **not** mean "this is already the document". It means: **this layer finished its work here; the next layer takes the same time slice.** + +## What is *not* true + +Before `transcript_sealed` the whole document is **not** mutable. + +- Closed spans stay on their places on the PCM axis. +- Utterances may not be reordered. Text may not be built from zero. +- The current tail may still evolve. +- Whisper may fill holes and replace weaker evidence inside a still-unsealed allowed span. +- The formatter works in parallel on closed fragments and keeps their order. +- Stop closes only the tail and assembles ready fragments. + +A first-wins final string is not enough. The real document is the ordered span ledger with provenance. Session seal closes the assembled result — it does not replace the architecture with one frozen variable. + +## Quality-report column roles + +| Column | Role | +| --- | --- | +| `raw` / `live` | live hypothesis | +| `post` / `layer1` | Whisper hole-fill | +| `sealed` | sealed span | +| `delivered` / `session` | session document | +| `ai` / `cloud` / `hq` | `HumanTriggeredProposal` — WER against a proposal does not promote it to document | + +## Forbidden (reports and agents) + +- `rewrite_from_zero` +- `reorder_spans` +- `hallucinate_into_silence` +- `full_file_in_automatic_pipeline` +- `auto_replace_after_transcript_sealed` +- `treat_committed_as_document` +- `treat_whole_text_mutable_until_session_seal` + +## How a quality HTML must behave + +The private quality HTML is a **Seal Atlas**, not a WER table with a banner. + +Gold visual: [`docs/quality-reports/seal-atlas.take01.html`](quality-reports/seal-atlas.take01.html) +(take 01 „no to dobra", replay `cff0817b…`, 44100 Hz, 2650112 samples). + +1. **One take, one clock.** X-axis is the capture PCM sample counter. Apple sealed spans, `SealedSpan.words`, Whisper segments, and Silero p(speech) share that axis. No reconstructed timeline. +2. **Production Silero.** Curve from the engine's embedded ONNX + default `VadConfig`, 32 ms chunks — `vad_atlas_probe`, not a toy VAD. +3. **Words from the live dump.** `CODESCRIBE_SEAL_ATLAS_DUMP` after session-end seal. Never rebuilt from the final string. +4. **Two grain classes, labeled.** + - *word-grain* — SFSpeech actually returned per-word pins (take 01: spans 3 and 9). + - *utterance-grain* — one “word” covering the whole Apple commit-to-commit window. Per-word payload is real where it exists and **not guaranteed**. +5. **Clock-lie** (`clock-lie`) is a first-class finding. Span 2 of take 01: 41 characters pinned to 100 ms (410 chars/s). Physically impossible. Silero can still say “speech 100%” — the range is not the range of that speech. Same class as `seal window unresolved`. +6. **Utterance-grain includes silence tails.** Span 8: 36% speech. The span range is the distance between Apple commits, not the speech outline. This is why W13-3B mints identity from Silero silence edges. +7. **Letters = interpolation.** Grapheme ticks inside a word are an even split of the word range, drawn as such. Not a measurement. Forced-aligner would be required for real grapheme times — we do not pretend to have one. +8. Whisper is drawn as `whisper_words` mapped **back** onto the same clock. HQ / Cloud / AI-formatted stay proposals. +9. `codescribe-corpus` machine JSON stays fail-closed on privacy. The private Atlas HTML is the only place bodies sit next to audio. +10. A WER table may exist as a footnote. It must not replace the atlas and must not present a full-file pass as the live engine. + +## Voice Lab handshake + +The operator does not open these HTML files from Finder. **Voice Lab** +(`vetcoders-tools` / `voice-lab`) is the console. Tab **Seal Atlas** lists +private HTML under: + +`~/.vibecrafted/artifacts/vetcoders/codescribe` (`CODESCRIBE_ARTIFACTS_DIR`) + +`codescribe-corpus` must write atlas HTML **into that tree**. Voice Lab then: + +1. Classifies by title + relative path (case-insensitive): + - contains `seal atlas` or `seal-atlas` → kind `seal_atlas` (sorted first) + - contains `quality` **and** `contract` → kind `quality_contract` + - else → `quality_report` (the old Qube WER page lands here — not the throne) +2. Pulls fact cards from `
    VALUELABEL
    ` + (take 01: 20/20 word-grain, 11 spans, 2 per-word, 1 clock-lie, Silero 0.5). +3. Iframes the file (`sandbox=allow-scripts`). Absolute paths never leave the + catalog JSON. + +A report that fails this handshake is invisible in the lab, whatever its WER. +The gold take 01 HTML already satisfies it. Qube `Codescribe Quality Report` +does not — that title is the thing we are retiring. + diff --git a/docs/THE_ENGINE_ROADMAP.md b/docs/THE_ENGINE_ROADMAP.md index a7571938..36724d5f 100644 --- a/docs/THE_ENGINE_ROADMAP.md +++ b/docs/THE_ENGINE_ROADMAP.md @@ -1,5 +1,11 @@ # THE ENGINE ROADMAP +The locked quality-report / seal / relay contract lives in +[`docs/THE_ENGINE_CONTRACT.md`](THE_ENGINE_CONTRACT.md) +(`the-engine/v1`, `core/quality/engine_contract.rs`). This roadmap is the +execution plan. Do not re-litigate the three bars here. + + **Codescribe STT engine — current state vs. target, sealed.** | | | diff --git a/docs/quality-reports/seal-atlas.take01.html b/docs/quality-reports/seal-atlas.take01.html new file mode 100644 index 00000000..e9810472 --- /dev/null +++ b/docs/quality-reports/seal-atlas.take01.html @@ -0,0 +1,158 @@ + + + + + + + +Seal Atlas — take 01 „no to dobra" + + + +
    +
    +

    Seal Atlas — take 01 „no to dobra"

    +

    Jeden take (60.1 s), jedna oś czasu: licznik próbek PCM z capture. Krzywa mowy policzona +produkcyjnym Silero VAD (embedded ONNX, config domyślny silnika), słowa pochodzą z +SealedSpan.words zrzuconych przez żywy tor Apple-live w replayu — +nie z żadnej rekonstrukcji.

    +
    +
    20/20słów word-grain ≥75% na mowie
    +
    11sealed spans
    +
    2spany z per-word pinami
    +
    1clock-lie (span 2)
    +
    0.5próg Silero
    +
    +
    + +
    +

    Pełny przebieg — 0…60 s

    +

    Góra: obwiednia PCM. Środek: p(mowa) Silero co 32 ms z progiem 0.5 (linia przerywana). +Dół: zapieczętowane spany Apple (z pinami słów tam, gdzie SFSpeech oddał segmenty) i segmenty Whispera +zmapowane wstecz na ten sam zegar. Najedź na box, żeby zobaczyć tekst i zakres.

    +
    0s5s10s15s20s25s30s35s40s45s50s55s60sPCM 44.1 kHzSilero p(mowa) · próg 0.5Apple · SealedSpan.wordsspan 1: No to dobra teraz generalnie powiem parę słów korzystając z surowej transkrypcji przez co. [0.00–20.00s] mowa 84%#1span 2: Mamy już pierwsze słowo do analizy czego. [20.00–20.10s] mowa 100%span 3: Ten plik WAV i puścił go na. [20.70–23.49s] mowa 98%#3ten [20.70–20.94s] mowa 100%plik wave [20.94–21.93s] mowa 100%plik w…i [22.14–22.35s] mowa 75%puścił [22.35–22.92s] mowa 100%puś…go [22.92–23.13s] mowa 100%na [23.13–23.49s] mowa 100%span 4: Nasz and point bo muszę mieć pewność Leksykon działa. [23.49–32.10s] mowa 76%#4span 5: Więc po prostu będę mówił z dupy. [32.10–35.40s] mowa 48%#5span 6: To aplikacja. [35.40–37.40s] mowa 95%#6span 7: Ta aplikacja. [37.40–39.80s] mowa 50%#7span 8: Stworzona. [39.80–43.20s] mowa 36%#8span 9: Duże bazy kodowe przestały być tajemnicą i czarną dziurą dla agentów jaj korzysta z Ruska. [43.98–53.79s] mowa 73%#9duże [43.98–44.28s] mowa 90%bazy [44.28–44.55s] mowa 100%kodowe [44.55–45.09s] mowa 100%przestały [45.27–45.66s] mowa 85%być [45.66–45.81s] mowa 100%tajemnicą [45.81–46.59s] mowa 100%taje…i [46.59–47.31s] mowa 100%iczarną [47.79–48.15s] mowa 75%dziurą [48.15–48.57s] mowa 100%dla [49.20–49.89s] mowa 100%dlaagentów [49.89–50.25s] mowa 100%jaj [50.25–50.52s] mowa 100%korzysta [52.20–52.62s] mowa 100%z Ruska [52.62–53.79s] mowa 100%z Ruskaspan 10: W wersji dwa. [53.79–57.30s] mowa 69%#10span 11: 000. [57.30–57.50s] mowa 43%Whisper · whisper_wordsNo to dobra. Teraz generalnie powiem parę słów, korzystając z surowej transkrypcji przez CodeScribe. [1.00–11.56s]Mamy już pierwsze słowo do analizy, wobec czego chcę, żebyś za chwilę dam [11.56–19.98s]żebyś wziął ten blik wave [20.10–21.96s]i puścił go na [21.96–23.48s]nasz endpoint, bo muszę mieć pewność, [25.49–27.51s]czy leksykon działa. [27.67–29.53s]Więc po prostu [31.33–32.09s]co tam obliwstwoj. [32.10–35.08s]Log3 to aplikacja [35.40–37.38s]toż ona. [38.90–39.78s]po to, aby [40.30–43.18s]Duże bazy kodowe przestały być tajemnicą i czarną dziurą dla agentów AI. [43.70–50.42s]Korzystam z Rusta. [52.12–53.78s]w wersji Tooltrain 2024. [54.79–57.29s]
    +
    +Silero p(mowa) +span word-grain (piny słów) +span utterance-grain (1 segment) +clock-lie +whisper_words +
    +
    + +
    +

    Zoom: span 3 — „Ten plik WAV i puścił go na" (20.7–23.5 s)

    +

    6 słów, każde z własnym zakresem próbek. Podział na litery wewnątrz słowa to +równomierna interpolacja (SFSpeech nie daje timingów grafemów) — jawnie oznaczona, nie pomiar.

    +
    21s22s23sPCM 44.1 kHzSilero p(mowa) · próg 0.5Apple · SealedSpan.wordsspan 3: Ten plik WAV i puścił go na. [20.70–23.49s] mowa 98%#3ten [20.70–20.94s] mowa 100%tenplik wave [20.94–21.93s] mowa 100%plik wavei [22.14–22.35s] mowa 75%ipuścił [22.35–22.92s] mowa 100%puściłgo [22.92–23.13s] mowa 100%gona [23.13–23.49s] mowa 100%naspan 4: Nasz and point bo muszę mieć pewność Leksykon działa. [23.49–32.10s] mowa 76%#4Whisper · whisper_wordsżebyś wziął ten blik wave [20.10–21.96s]i puścił go na [21.96–23.48s]
    +
    + +
    +

    Zoom: span 9 — 14 słów (44.0–53.8 s)

    +

    Najdłuższy word-grain span take'a. Widać przerwy między słowami pokrywające się z dolinami +Silero („czarną" zaczyna się po 480 ms ciszy — VAD i pin zgadzają się co do niej).

    +
    44s45s46s47s48s49s50s51s52s53s54sPCM 44.1 kHzSilero p(mowa) · próg 0.5Apple · SealedSpan.wordsspan 9: Duże bazy kodowe przestały być tajemnicą i czarną dziurą dla agentów jaj korzysta z Ruska. [43.98–53.79s] mowa 73%#9duże [43.98–44.28s] mowa 90%dużebazy [44.28–44.55s] mowa 100%bazykodowe [44.55–45.09s] mowa 100%kodoweprzestały [45.27–45.66s] mowa 85%być [45.66–45.81s] mowa 100%tajemnicą [45.81–46.59s] mowa 100%tajemnicąi [46.59–47.31s] mowa 100%iczarną [47.79–48.15s] mowa 75%dziurą [48.15–48.57s] mowa 100%dziurądla [49.20–49.89s] mowa 100%dlaagentów [49.89–50.25s] mowa 100%jaj [50.25–50.52s] mowa 100%jajkorzysta [52.20–52.62s] mowa 100%z Ruska [52.62–53.79s] mowa 100%z Ruskaspan 10: W wersji dwa. [53.79–57.30s] mowa 69%#10Whisper · whisper_wordsDuże bazy kodowe przestały być tajemnicą i czarną dziurą dla agentów AI. [43.70–50.42s]Korzystam z Rusta. [52.12–53.78s]
    +
    + +
    +

    Każde słowo word-grain vs Silero

    +

    Udział chunków z p(mowa) ≥ 0.5 wewnątrz zakresu słowa.

    +
    + + +
    spansłowozakres [s]czasmowa
    #3ten20.70–20.94240 ms100%
    #3plik wave20.94–21.93990 ms100%
    #3i22.14–22.35210 ms75%
    #3puścił22.35–22.92570 ms100%
    #3go22.92–23.13210 ms100%
    #3na23.13–23.49360 ms100%
    #9duże43.98–44.28300 ms90%
    #9bazy44.28–44.55270 ms100%
    #9kodowe44.55–45.09540 ms100%
    #9przestały45.27–45.66390 ms85%
    #9być45.66–45.81150 ms100%
    #9tajemnicą45.81–46.59780 ms100%
    #9i46.59–47.31720 ms100%
    #9czarną47.79–48.15360 ms75%
    #9dziurą48.15–48.57420 ms100%
    #9dla49.20–49.89690 ms100%
    #9agentów49.89–50.25360 ms100%
    #9jaj50.25–50.52270 ms100%
    #9korzysta52.20–52.62420 ms100%
    #9z Ruska52.62–53.791170 ms100%
    +
    + +
    +

    Uczciwe wnioski

    +
      +
    • Piny są prawdziwe tam, gdzie istnieją. 20/20 słów z per-word pinami leży w ≥75% +na chunkach mowy wg Silero; minimum to 75% („i", 210 ms — krótkie spójniki łapią sąsiadującą ciszę). +Czasy trwania są fizjologiczne (150–990 ms na słowo).
    • +
    • Dwie klasy ziarna. Tylko 2 z 11 spanów mają per-word piny — SFSpeech oddaje segmenty +kapryśnie; pozostałe spany niosą jeden „word" o ziarnie całego utterance. Per-word payload +jest więc realny, ale nie gwarantowany.
    • +
    • Span 2 to kłamstwo zegarowe na żywym okazie: 41 znaków przypięte do 100 ms +(410 znaków/s — fizycznie niemożliwe). To ten sam rodzaj rozjazdu, który łapią WARN-y +„seal window unresolved". Silero mówi „mowa 100%", ale zakres nie jest zakresem tej mowy.
    • +
    • Utterance-grain spany zawierają ogony ciszy (span 8: 36% mowy) — zakres spanu to +odległość między commitami Apple, nie obrys mowy. Dokładnie dlatego W13-3B chce mintować +tożsamości z krawędzi ciszy Silero.
    • +
    • Litery = interpolacja. Rozbicie liter jest równomiernym podziałem zakresu słowa, +oznaczonym na grafie; realne timingi grafemów wymagałyby forced-alignera — nie udajemy, że je mamy.
    • +
    +

    Źródła: CODESCRIBE_SEAL_ATLAS_DUMP (nowa powierzchnia +dowodowa w apple_live_session) + vad_atlas_probe (nowy example w core). +Sesja replay cff0817b…, sample_rate 44100 Hz, 2650112 próbek.

    +
    +
    + + diff --git a/macos/Codescribe/Bridge/codescribe_ffi.swift b/macos/Codescribe/Bridge/codescribe_ffi.swift index 9e3ab590..e02095f9 100644 --- a/macos/Codescribe/Bridge/codescribe_ffi.swift +++ b/macos/Codescribe/Bridge/codescribe_ffi.swift @@ -1305,6 +1305,19 @@ public protocol CodescribeConfigProtocol: AnyObject, Sendable { */ func onboardingProgress() -> UInt32 + /** + * Reset only durable Agent state. Conversations and tool/MCP files are + * moved to Trash; provider credentials are deleted from Keychain and are + * intentionally not recoverable. This deliberately does not use the + * full-app reset fence or its broad root move. + */ + func resetAgentData() throws + + /** + * Preview the Agent-only reset without changing disk or Keychain state. + */ + func resetAgentPreview() -> CsAgentResetPreview + /** * Move all local codescribe data to a recoverable folder in the user's * Trash, append an external audit entry, and optionally remove Keychain @@ -1793,6 +1806,30 @@ open func onboardingProgress() -> UInt32 { self.uniffiCloneHandle(),$0 ) }) +} + + /** + * Reset only durable Agent state. Conversations and tool/MCP files are + * moved to Trash; provider credentials are deleted from Keychain and are + * intentionally not recoverable. This deliberately does not use the + * full-app reset fence or its broad root move. + */ +open func resetAgentData()throws {try rustCallWithError(FfiConverterTypeCsError_lift) { + uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_agent_data( + self.uniffiCloneHandle(),$0 + ) +} +} + + /** + * Preview the Agent-only reset without changing disk or Keychain state. + */ +open func resetAgentPreview() -> CsAgentResetPreview { + return try! FfiConverterTypeCsAgentResetPreview_lift(try! rustCall() { + uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_agent_preview( + self.uniffiCloneHandle(),$0 + ) +}) } /** @@ -7140,6 +7177,71 @@ public func FfiConverterTypeCsAgentAvailability_lower(_ value: CsAgentAvailabili } +/** + * Non-secret impact summary for the narrowly-scoped Agent reset. Unlike the + * app-data reset, this never counts or touches recordings, transcripts, + * prompts, lexicon data, license state, or dictation preferences. + */ +public struct CsAgentResetPreview: Equatable, Hashable { + public var threads: UInt64 + public var files: UInt64 + public var totalBytes: UInt64 + public var secretsPresent: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(threads: UInt64, files: UInt64, totalBytes: UInt64, secretsPresent: Bool) { + self.threads = threads + self.files = files + self.totalBytes = totalBytes + self.secretsPresent = secretsPresent + } + + +} + +#if compiler(>=6) +extension CsAgentResetPreview: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCsAgentResetPreview: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CsAgentResetPreview { + return + try CsAgentResetPreview( + threads: FfiConverterUInt64.read(from: &buf), + files: FfiConverterUInt64.read(from: &buf), + totalBytes: FfiConverterUInt64.read(from: &buf), + secretsPresent: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: CsAgentResetPreview, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.threads, into: &buf) + FfiConverterUInt64.write(value.files, into: &buf) + FfiConverterUInt64.write(value.totalBytes, into: &buf) + FfiConverterBool.write(value.secretsPresent, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsAgentResetPreview_lift(_ buf: RustBuffer) throws -> CsAgentResetPreview { + return try FfiConverterTypeCsAgentResetPreview.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsAgentResetPreview_lower(_ value: CsAgentResetPreview) -> RustBuffer { + return FfiConverterTypeCsAgentResetPreview.lower(value) +} + + /** * Agentic-lane readiness verdict + rows. `ready` reflects the CORE capability * gate only (assistive provider configured + its API key set + native tools @@ -13832,6 +13934,12 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_onboarding_progress() != 28580) { return InitializationResult.apiChecksumMismatch } + if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_agent_data() != 41646) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_agent_preview() != 8135) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_app_data() != 24728) { return InitializationResult.apiChecksumMismatch } diff --git a/macos/Codescribe/Bridge/codescribe_ffiFFI.h b/macos/Codescribe/Bridge/codescribe_ffiFFI.h index bcc6d2c9..c90a36da 100644 --- a/macos/Codescribe/Bridge/codescribe_ffiFFI.h +++ b/macos/Codescribe/Bridge/codescribe_ffiFFI.h @@ -798,6 +798,16 @@ RustBuffer uniffi_codescribe_ffi_fn_method_codescribeconfig_onboarding_mode(uint uint32_t uniffi_codescribe_ffi_fn_method_codescribeconfig_onboarding_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_AGENT_DATA +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_AGENT_DATA +void uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_agent_data(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_AGENT_PREVIEW +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_AGENT_PREVIEW +RustBuffer uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_agent_preview(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_APP_DATA #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_APP_DATA void uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_app_data(uint64_t ptr, int8_t include_keys, int8_t include_prompts, RustCallStatus *_Nonnull out_status @@ -2257,6 +2267,18 @@ uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_onboarding_mode( #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_ONBOARDING_PROGRESS uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_onboarding_progress(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_AGENT_DATA +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_AGENT_DATA +uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_agent_data(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_AGENT_PREVIEW +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_AGENT_PREVIEW +uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_agent_preview(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_APP_DATA diff --git a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift index 7060f504..20c79502 100644 --- a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift +++ b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift @@ -2080,7 +2080,9 @@ final class AgentChatStore: ObservableObject { let attachments: [PersistedAttachmentMetadata] } - private static let attachmentMetadataDefaultsKey = "AgentChatStore.attachmentMetadata.v1" + /// Agent-owned attachment sidecar. Reset Agent clears this alongside queued + /// accepted turns; UI layout preferences intentionally remain untouched. + static let attachmentMetadataDefaultsKey = "AgentChatStore.attachmentMetadata.v1" // MARK: Durable accepted-turn sidecar (queue persistence) diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift index a94c2bca..c47f8ea2 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift @@ -44,10 +44,6 @@ struct DictationOverlayView: View { private let transcriptMinHeight: CGFloat = 84 private let buttonRadius: CGFloat = 10 - /// Anchor id for the live transcript's tail. `scrollTo` pins it to the bottom on - /// every append so the newest text stays visible without any user interaction. - private let transcriptBottomAnchor = "overlayTranscriptBottom" - var body: some View { GlassPanel(cornerRadius: CSRadius.window) { VStack(alignment: .leading, spacing: 0) { @@ -268,8 +264,12 @@ struct DictationOverlayView: View { listeningBody .transition(.opacity.combined(with: .offset(y: 8))) case .formatted: + // TextEditor is an AppKit-backed platform view. Moving it with a SwiftUI + // transition can leave its native text layer painting at the old frame + // while the surrounding stack has already settled, which lets transcript + // glyphs bleed through the action row during finalization. The FINAL body + // swaps in place; the containing clip below is the hard sibling boundary. formattedBody - .transition(.opacity.combined(with: .offset(y: 8))) case .noSpeech: noSpeechBody .transition(.opacity.combined(with: .offset(y: 8))) @@ -284,6 +284,9 @@ struct DictationOverlayView: View { .padding(.horizontal, 20) .padding(.top, 4) .padding(.bottom, 10) + // Platform-backed TextEditor content must never paint into the action/footer + // siblings, including the mode-transition and live-resize frames. + .clipped() .animation(CSMotion.floatIn, value: state.mode) } @@ -301,74 +304,32 @@ struct DictationOverlayView: View { } } - /// Scrollable live transcript that ALWAYS follows the tail: every append pins - /// the view to the newest text with no user interaction required. The follow is - /// unconditional and intentional here because this scroll only exists while - /// `.listening` — during a hold-to-talk session the modifier key is held, so the - /// user physically cannot scroll, and pinning to the bottom is the only way the - /// growing transcript stays legible (an earlier "pause on manual scroll up" - /// heuristic mis-read normal content overflow as a scroll gesture and killed the - /// follow exactly when it was needed, hiding the newest chunk). Manual scroll is - /// owned by the terminal `.formatted` TextEditor, which is never driven by this. - /// A `minHeight` reserves ~2–3 lines so the tail is visible even at the min - /// window size instead of collapsing behind the waveform. + /// Native live transcript: follows the newest words until the user clicks or + /// selects an older phrase. The `NSTextView` keeps that selection stable across + /// ongoing stream updates, so drag selection, Cmd-C and context-menu Copy work + /// during recording without stopping capture. A `minHeight` reserves ~2–3 lines + /// at the window floor. private var transcriptScroll: some View { - ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .bottom, spacing: 2) { - if state.highlightsEnabled, !state.highlights.isEmpty { - OverlayHighlightCanvas( - runs: state.highlightCanvasRuns, - selectedId: state.selectedHighlightId, - onSelect: { state.selectHighlight($0) } - ) - } else { - Text(state.listeningDisplay) - .csFont(15, .medium) - .lineSpacing(5) - .foregroundStyle(CSColor.textBody) - .fixedSize(horizontal: false, vertical: true) - .accessibilityIdentifier("overlay-transcript-live") - } - BlinkingCaret() - } - if state.highlightsEnabled { - OverlayHighlightTeachBar( - highlights: state.highlights, - selectedId: state.selectedHighlightId, - onSelect: { state.selectHighlight($0) }, - onTeach: { state.sendHighlightToTeach($0) } - ) - .padding(.top, 8) - } - Color.clear - .frame(height: 1) - .id(transcriptBottomAnchor) + VStack(alignment: .leading, spacing: 0) { + LiveTranscriptTextView(runs: state.highlightCanvasRuns) + .overlay(alignment: .bottomTrailing) { + BlinkingCaret() + .padding(.trailing, 3) + .allowsHitTesting(false) } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(minHeight: transcriptMinHeight) - .accessibilityIdentifier("overlay-transcript-area") - .onChange(of: state.listeningDisplay) { _, _ in - scrollToTail(proxy) - } - .onAppear { scrollToTail(proxy) } - } - } - - /// Pin the live transcript to its bottom anchor. A short ease keeps rapid - /// word-by-word appends from snapping harshly while still tracking the tail. - private func scrollToTail(_ proxy: ScrollViewProxy) { - // Defer one runloop tick: `onChange` fires before SwiftUI lays out the - // freshly appended text, so scrolling synchronously targets the previous - // content height and clips the newest word. By the next tick the bottom - // anchor sits below the new tail. - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.14)) { - proxy.scrollTo(transcriptBottomAnchor, anchor: .bottom) + .frame(minHeight: transcriptMinHeight) + .accessibilityIdentifier("overlay-transcript-area") + if state.highlightsEnabled { + OverlayHighlightTeachBar( + highlights: state.highlights, + selectedId: state.selectedHighlightId, + onSelect: { state.selectHighlight($0) }, + onTeach: { state.sendHighlightToTeach($0) } + ) + .padding(.top, 8) } } + .frame(maxWidth: .infinity, alignment: .leading) } private var formattedBody: some View { diff --git a/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift b/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift new file mode 100644 index 00000000..89464867 --- /dev/null +++ b/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift @@ -0,0 +1,176 @@ +import AppKit +import SwiftUI + +/// Selection rules for the live transcript's native text view. +/// +/// The stream may append or replace its open tail while the user has an older +/// phrase selected. AppKit resets selection when its storage is replaced, so the +/// representable snapshots and restores a clamped UTF-16 range on every update. +/// Keeping this policy pure makes the P0 behavior testable without a recording. +enum LiveTranscriptSelectionPolicy { + static func preservedRange(_ selection: NSRange, updatedLength: Int) -> NSRange { + let safeLength = max(0, updatedLength) + let location = min(max(0, selection.location), safeLength) + let length = min(max(0, selection.length), safeLength - location) + return NSRange(location: location, length: length) + } + + static func followsTail(selection: NSRange, textLength: Int) -> Bool { + selection.length == 0 && selection.location >= max(0, textLength) + } +} + +/// Read-only AppKit transcript surface used while recording. +/// +/// `Text` plus SwiftUI's selection overlay loses its selection whenever the +/// rapidly-changing value is rebuilt. A real `NSTextView` owns the responder +/// chain instead: drag selection, Cmd-C, Select All and the standard context +/// menu keep working while the recording and transcript updates continue. +struct LiveTranscriptTextView: NSViewRepresentable { + let runs: [OverlayCanvasRun] + @Environment(\.csTextScale) private var textScale + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSScrollView { + let textView = Self.makeTextView() + textView.delegate = context.coordinator + + let scrollView = NSScrollView() + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.horizontalScrollElasticity = .none + scrollView.documentView = textView + + update(textView, coordinator: context.coordinator) + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? LiveTranscriptNativeTextView else { return } + update(textView, coordinator: context.coordinator) + } + + static func makeTextView() -> LiveTranscriptNativeTextView { + let textView = LiveTranscriptNativeTextView(usingTextLayoutManager: true) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = true + textView.importsGraphics = false + textView.allowsUndo = false + textView.drawsBackground = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainerInset = NSSize(width: 0, height: 0) + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: 0, + height: CGFloat.greatestFiniteMagnitude + ) + textView.setAccessibilityIdentifier("overlay-transcript-live") + textView.setAccessibilityLabel("Live transcript") + return textView + } + + private func update( + _ textView: LiveTranscriptNativeTextView, + coordinator: Coordinator + ) { + let rendered = attributedTranscript() + guard textView.attributedString() != rendered else { return } + + let previousSelection = textView.selectedRange() + let wasFollowingTail = coordinator.followsTail + coordinator.applyingUpdate = true + textView.textStorage?.setAttributedString(rendered) + + let updatedLength = rendered.length + if previousSelection.length > 0 || !wasFollowingTail { + textView.setSelectedRange( + LiveTranscriptSelectionPolicy.preservedRange( + previousSelection, + updatedLength: updatedLength + ) + ) + } else { + let tail = NSRange(location: updatedLength, length: 0) + textView.setSelectedRange(tail) + DispatchQueue.main.async { [weak textView, weak coordinator] in + guard let textView, coordinator?.followsTail == true else { return } + textView.scrollRangeToVisible(tail) + } + } + coordinator.applyingUpdate = false + } + + private func attributedTranscript() -> NSAttributedString { + let size = 15 * textScale + let descriptor = NSFontDescriptor(fontAttributes: [ + .family: FontLoader.spaceGrotesk, + .traits: [NSFontDescriptor.TraitKey.weight: NSFont.Weight.medium.rawValue], + ]) + let font = + NSFont(descriptor: descriptor, size: size) + ?? .systemFont(ofSize: size, weight: .medium) + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 5 + let result = NSMutableAttributedString() + + for run in runs { + let text: String + let color: NSColor + var extra: [NSAttributedString.Key: Any] = [:] + switch run { + case .text(let value): + text = value + color = NSColor(CSColor.textBody) + case .highlight(let highlight): + text = highlight.after + switch highlight.kind { + case .lexiconCorrected: + color = NSColor(highlight.taught ? CSColor.oliveLight : CSColor.terracottaLight) + case .speechGap: + color = NSColor(CSColor.amber) + extra[.underlineStyle] = NSUnderlineStyle.single.rawValue + extra[.underlineColor] = NSColor(CSColor.amber) + } + } + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + attributes.merge(extra) { _, replacement in replacement } + result.append(NSAttributedString(string: text, attributes: attributes)) + } + return result + } + + @MainActor + final class Coordinator: NSObject, NSTextViewDelegate { + var followsTail = true + var applyingUpdate = false + + func textViewDidChangeSelection(_ notification: Notification) { + guard !applyingUpdate, + let textView = notification.object as? NSTextView + else { return } + followsTail = LiveTranscriptSelectionPolicy.followsTail( + selection: textView.selectedRange(), + textLength: (textView.string as NSString).length + ) + } + } +} + +/// First-click selection is important because the overlay is deliberately a +/// non-activating panel: it must not steal focus merely by appearing, but an +/// explicit click in the transcript must immediately begin a drag selection. +final class LiveTranscriptNativeTextView: NSTextView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} diff --git a/macos/Codescribe/Screens/Settings/SettingsEngine.swift b/macos/Codescribe/Screens/Settings/SettingsEngine.swift index 36735aec..714d677a 100644 --- a/macos/Codescribe/Screens/Settings/SettingsEngine.swift +++ b/macos/Codescribe/Screens/Settings/SettingsEngine.swift @@ -80,6 +80,8 @@ protocol SettingsEngine { // optionally remove Keychain keys. MCP-only clear is a separate concern. func resetPreview() -> CsResetPreview func resetAppData(includeKeys: Bool, includePrompts: Bool) throws + func resetAgentPreview() -> CsAgentResetPreview + func resetAgentData() throws func clearMcpConfiguration() throws } @@ -184,6 +186,8 @@ final class RealSettingsEngine: SettingsEngine { func resetAppData(includeKeys: Bool, includePrompts: Bool) throws { try config.resetAppData(includeKeys: includeKeys, includePrompts: includePrompts) } + func resetAgentPreview() -> CsAgentResetPreview { config.resetAgentPreview() } + func resetAgentData() throws { try config.resetAgentData() } func clearMcpConfiguration() throws { try config.clearMcpConfiguration() } } @@ -204,11 +208,13 @@ struct MockSettingsEngine: SettingsEngine { var lexiconEntriesLoader: (() throws -> [CsLexiconEntry])? var audioSnapshot: CsAudioInputSnapshot = .sample var resetPreviewValue: CsResetPreview = .sample + var agentResetPreviewValue: CsAgentResetPreview = .sample var formattingSnapshot: CsPromptSnapshot = .sampleFormatting var assistiveSnapshot: CsPromptSnapshot = .sampleAssistive var promptSaveObserver: ((String, String) throws -> Void)? var promptRestoreObserver: ((String) throws -> Void)? var resetAppDataObserver: ((Bool, Bool) throws -> Void)? + var resetAgentDataObserver: (() throws -> Void)? var clearMcpConfigurationObserver: (() throws -> Void)? var settingsLoader: (() -> CsSettings)? var updateConfigManyObserver: (([CsConfigEntry]) throws -> Void)? @@ -367,6 +373,8 @@ struct MockSettingsEngine: SettingsEngine { func resetAppData(includeKeys: Bool, includePrompts: Bool) throws { try resetAppDataObserver?(includeKeys, includePrompts) } + func resetAgentPreview() -> CsAgentResetPreview { agentResetPreviewValue } + func resetAgentData() throws { try resetAgentDataObserver?() } func clearMcpConfiguration() throws { try clearMcpConfigurationObserver?() } @@ -394,6 +402,15 @@ extension CsResetPreview { ) } +extension CsAgentResetPreview { + static let sample = CsAgentResetPreview( + threads: 3, + files: 8, + totalBytes: 12_288, + secretsPresent: true + ) +} + extension CsPromptSnapshot { static let sampleFormatting = CsPromptSnapshot( content: CsSettings.samplePrompt, diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 2b2471cd..549e18f4 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -545,6 +545,10 @@ func resetConfirmationMatches(_ text: String) -> Bool { text == "RESET" } +func resetAgentConfirmationMatches(_ text: String) -> Bool { + text == "RESET AGENT" +} + /// Rust marks failures that occurred after the first irreversible data move. /// Those are errors for audit/recovery, but staying in the current process is /// no longer safe because its app-data plane is deliberately latched. @@ -552,6 +556,10 @@ func resetFailureRequiresRelaunch(_ description: String) -> Bool { description.contains("CODESCRIBE_RESET_RELAUNCH_REQUIRED") } +func agentResetFailureRequiresRelaunch(_ description: String) -> Bool { + description.contains("CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED") +} + func resetImpactSummary(_ preview: CsResetPreview) -> String { let recordings = preview.audioFiles == 1 ? "recording" : "recordings" let days = preview.transcriptDays == 1 ? "day" : "days" @@ -788,11 +796,29 @@ extension UInt64 { /// guard would terminate the freshly-launched copy. @MainActor enum AppRelaunch { + /// Erases only durable Agent queue/attachment state. Agent window layout and + /// all non-Agent preferences (including hotkeys and dictation) deliberately + /// survive this narrow reset. + static func clearAgentDefaults(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: AgentChatStore.acceptedTurnsDefaultsKey) + defaults.removeObject(forKey: AgentChatStore.attachmentMetadataDefaultsKey) + defaults.synchronize() + } + + static func clearAgentDefaultsAndRelaunch() { + clearAgentDefaults() + relaunch() + } + static func clearDefaultsAndRelaunch() { if let bundleId = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: bundleId) UserDefaults.standard.synchronize() } + relaunch() + } + + private static func relaunch() { let bundlePath = Bundle.main.bundlePath let task = Process() task.launchPath = "/bin/sh" @@ -925,6 +951,7 @@ final class SettingsViewModel: ObservableObject { @Published private(set) var audioInput: CsAudioInputSnapshot @Published private(set) var audioInputReadError: String? @Published private(set) var resetPreview: CsResetPreview + @Published private(set) var agentResetPreview: CsAgentResetPreview @Published private(set) var licenseStatus: CsLicenseStatus /// Provider ids with a "Sign in with ChatGPT" flow in flight (browser open, /// local callback server listening). Guards double-clicks. @@ -1031,6 +1058,7 @@ final class SettingsViewModel: ObservableObject { self.audioInput = .sample self.audioInputReadError = nil self.resetPreview = .sample + self.agentResetPreview = .sample self.licenseStatus = self.licenseService.status // K4: tray cycles arrive on the bus; reload Settings badge display. // Register after every stored property is initialized (Swift init order). @@ -1441,6 +1469,45 @@ final class SettingsViewModel: ObservableObject { AppRelaunch.clearDefaultsAndRelaunch() } + // MARK: - Reset Agent (narrow destructive action) + + func refreshAgentResetPreview() { + guard let engine else { return } + agentResetPreview = engine.resetAgentPreview() + } + + func resetAgentImpactDescription() -> String { + let preview = agentResetPreview + let threadWord = preview.threads == 1 ? "thread" : "threads" + let fileWord = preview.files == 1 ? "file" : "files" + let secretState = + preview.secretsPresent + ? "Agent provider and MCP connector secrets are present and will be deleted permanently." + : "No Agent provider or MCP connector secrets are currently stored." + return + "Moves \(preview.threads) Agent \(threadWord) and \(preview.files) Agent \(fileWord) to Trash. " + + secretState + + " Recordings, transcriptions, dictionary and lexicon data, quality corpus and reports, prompts, audio, hotkeys, dictation settings, license, and macOS permissions stay unchanged." + } + + func resetAgentData() { + guard let engine else { return } + do { + try engine.resetAgentData() + mcpTestResults = [:] + reloadMcpServers() + refreshAgentStatus() + refreshAgentResetPreview() + AppRelaunch.clearAgentDefaultsAndRelaunch() + } catch { + let description = String(describing: error) + lastError = description + if agentResetFailureRequiresRelaunch(description) { + AppRelaunch.clearAgentDefaultsAndRelaunch() + } + } + } + func clearMcpConfiguration() { guard let engine else { return } do { diff --git a/macos/Codescribe/Screens/Settings/UserPanel.swift b/macos/Codescribe/Screens/Settings/UserPanel.swift index 022b8d2f..bce2f114 100644 --- a/macos/Codescribe/Screens/Settings/UserPanel.swift +++ b/macos/Codescribe/Screens/Settings/UserPanel.swift @@ -188,6 +188,9 @@ struct UserPanel: View { } .padding(.top, 11) + ResetAgentSection(model: model) + .padding(.top, 24) + ResetAppDataSection(model: model) .padding(.top, 30) } @@ -263,6 +266,75 @@ struct UserPanel: View { // MARK: - Danger zone +/// A deliberately narrow reset for Agent state. It is separate from the full +/// app-data reset so it cannot clear dictation, recordings, prompts or license. +private struct ResetAgentSection: View { + @ObservedObject var model: SettingsViewModel + @State private var confirming = false + @State private var confirmationText = "" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionLabel("Reset Agent") + .foregroundStyle(CSColor.dangerLight) + + Text( + "Moves only Agent conversations, runtime identity, MCP and tool state to Trash. " + + "Agent provider and MCP connector secrets are deleted permanently. " + + "Recordings, transcriptions, dictionary, lexicon, quality reports, prompts, audio, hotkeys, dictation, license, and macOS permissions are preserved." + ) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 6) + + Button(role: .destructive) { + model.refreshAgentResetPreview() + confirmationText = "" + confirming = true + } label: { + Text("Reset Agent…") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.dangerLight) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.danger.opacity(0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.danger.opacity(0.42), lineWidth: 1) + ) + } + .csFocusRing(cornerRadius: 8) + .padding(.top, 13) + .accessibilityLabel("Reset Agent. Destructive action.") + .accessibilityHint( + "Shows Agent-only impact and requires typing RESET AGENT before continuing.") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .fill(CSColor.danger.opacity(0.055)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.danger.opacity(0.55), lineWidth: 1) + ) + .alert("Reset Agent?", isPresented: $confirming) { + TextField("Type RESET AGENT to continue", text: $confirmationText) + Button("Cancel", role: .cancel) { confirmationText = "" } + Button("Reset Agent", role: .destructive) { model.resetAgentData() } + .disabled(!resetAgentConfirmationMatches(confirmationText)) + } message: { + Text(model.resetAgentImpactDescription()) + } + } +} + /// The full-data reset lives only at the foot of User settings, away from MCP /// editing. Data is recoverable from Trash; Keychain deletion remains opt-in. private struct ResetAppDataSection: View { diff --git a/macos/CodescribeTests/LiveTranscriptTextViewTests.swift b/macos/CodescribeTests/LiveTranscriptTextViewTests.swift new file mode 100644 index 00000000..7e3113b1 --- /dev/null +++ b/macos/CodescribeTests/LiveTranscriptTextViewTests.swift @@ -0,0 +1,73 @@ +import AppKit +import XCTest + +@testable import Codescribe + +@MainActor +final class LiveTranscriptTextViewTests: XCTestCase { + func testLiveTranscriptIsReadOnlySelectableAndAcceptsFirstClick() { + let textView = LiveTranscriptTextView.makeTextView() + + XCTAssertFalse(textView.isEditable) + XCTAssertTrue(textView.isSelectable) + XCTAssertTrue(textView.acceptsFirstMouse(for: nil)) + XCTAssertEqual( + textView.accessibilityIdentifier(), + "overlay-transcript-live" + ) + } + + func testSelectionSurvivesAnAppendAtTheSameUtf16Range() { + let selected = NSRange(location: 6, length: 8) + + XCTAssertEqual( + LiveTranscriptSelectionPolicy.preservedRange(selected, updatedLength: 42), + selected, + "new live words must not throw away an earlier selection" + ) + XCTAssertFalse( + LiveTranscriptSelectionPolicy.followsTail(selection: selected, textLength: 42), + "an active selection pauses automatic tail scrolling, not transcription" + ) + } + + func testSelectionIsClampedWhenTheOpenTailIsReplaced() { + XCTAssertEqual( + LiveTranscriptSelectionPolicy.preservedRange( + NSRange(location: 8, length: 20), + updatedLength: 15 + ), + NSRange(location: 8, length: 7) + ) + XCTAssertTrue( + LiveTranscriptSelectionPolicy.followsTail( + selection: NSRange(location: 15, length: 0), + textLength: 15 + ) + ) + } + + func testNativeCopyUsesOnlyTheCurrentSelection() throws { + let pasteboard = NSPasteboard.general + let oldItems: [NSPasteboardItem] = (pasteboard.pasteboardItems ?? []).map { item in + let copy = NSPasteboardItem() + for type in item.types { + if let data = item.data(forType: type) { + copy.setData(data, forType: type) + } + } + return copy + } + defer { + pasteboard.clearContents() + pasteboard.writeObjects(oldItems) + } + + let textView = LiveTranscriptTextView.makeTextView() + textView.string = "alpha beta gamma" + textView.setSelectedRange(NSRange(location: 6, length: 4)) + textView.copy(nil) + + XCTAssertEqual(pasteboard.string(forType: .string), "beta") + } +} diff --git a/macos/CodescribeTests/OverlayStateTests.swift b/macos/CodescribeTests/OverlayStateTests.swift index e2f3db76..32ecd7cd 100644 --- a/macos/CodescribeTests/OverlayStateTests.swift +++ b/macos/CodescribeTests/OverlayStateTests.swift @@ -889,7 +889,8 @@ final class OverlayStateTests: XCTestCase { state.finishControllerRecording() XCTAssertEqual(state.formattedText, "Surowe słowa operatora.") - XCTAssertTrue(state.canRevert, "auto-formatted FINAL must offer Revert to the raw first version") + XCTAssertTrue( + state.canRevert, "auto-formatted FINAL must offer Revert to the raw first version") state.revertFormat() XCTAssertEqual(state.formattedText, "surowe słowa operatora") XCTAssertFalse(state.canRevert) @@ -1523,12 +1524,14 @@ final class OverlayStateTests: XCTestCase { ], preview: "dalej" ) - XCTAssertEqual(runs, [ - .highlight( - OverlayCanvas.lexiconHighlight( - utteranceId: 1, start: 0, replacement: "Reports", before: "RIPOS")!), - .text(" i Edyta dalej"), - ]) + XCTAssertEqual( + runs, + [ + .highlight( + OverlayCanvas.lexiconHighlight( + utteranceId: 1, start: 0, replacement: "Reports", before: "RIPOS")!), + .text(" i Edyta dalej"), + ]) } func testHighlightScreenshotRendersLexiconAndGap() throws { @@ -1571,4 +1574,64 @@ final class OverlayStateTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: dest.path)) } + @MainActor + func testFormattedOverlayMinimumHeightSnapshotRenders() throws { + let state = OverlayState.previewListening() + state.formattedText = Array( + repeating: + "Choose Insert to paste the text where you want it and press Return. The clipboard is untouched.", + count: 20 + ).joined(separator: "\n") + let size = CGSize( + width: 617, + height: DictationOverlayWindow.minSize.height + ) + let hostingView = NSHostingView( + rootView: DictationOverlayView(state: state) + .environment(\.csTextScale, 0.8) + .frame(width: size.width, height: size.height) + .preferredColorScheme(.dark) + ) + hostingView.frame = CGRect(origin: .zero, size: size) + hostingView.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.03)) + state.mode = .formatted + hostingView.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.03)) + guard let bitmap = hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds) else { + return XCTFail("could not allocate the formatted overlay bitmap") + } + hostingView.cacheDisplay(in: hostingView.bounds, to: bitmap) + guard let png = bitmap.representation(using: .png, properties: [:]) else { + XCTFail("could not render the formatted overlay") + return + } + let dest = FileManager.default.temporaryDirectory + .appendingPathComponent("codescribe-formatted-overlay-min-height.png") + try png.write(to: dest) + XCTAssertGreaterThan(png.count, 800) + + // The middle of the action row is deliberately empty between To Agent and + // Close. Bright pixels here mean the native TextEditor escaped its body and + // painted transcript glyphs underneath the action controls (the observed + // live FINAL regression). Hairlines and glass stay below this threshold. + var leakedBrightPixels = 0 + for x in 330..<520 { + for y in 35..<65 { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { + continue + } + if color.redComponent > 0.7 && color.greenComponent > 0.7 + && color.blueComponent > 0.7 && color.alphaComponent > 0.5 + { + leakedBrightPixels += 1 + } + } + } + XCTAssertLessThan( + leakedBrightPixels, 5, + "formatted transcript painted into the action-row spacer" + ) + } + } diff --git a/macos/CodescribeTests/SettingsTruthTests.swift b/macos/CodescribeTests/SettingsTruthTests.swift index 67f5dfa8..bf5b1093 100644 --- a/macos/CodescribeTests/SettingsTruthTests.swift +++ b/macos/CodescribeTests/SettingsTruthTests.swift @@ -924,6 +924,61 @@ final class SettingsTruthTests: XCTestCase { ) } + func testAgentResetIsSeparatelyConfirmedAndNamesPreservedSurfaces() throws { + var calls = 0 + let preview = CsAgentResetPreview( + threads: 2, + files: 5, + totalBytes: 2_048, + secretsPresent: true + ) + let engine = MockSettingsEngine( + agentResetPreviewValue: preview, + resetAgentDataObserver: { calls += 1 } + ) + let model = SettingsViewModel(engine: engine) + + model.refreshAgentResetPreview() + XCTAssertEqual(model.agentResetPreview.threads, 2) + XCTAssertTrue(resetAgentConfirmationMatches("RESET AGENT")) + XCTAssertFalse(resetAgentConfirmationMatches("RESET")) + XCTAssertFalse(resetAgentConfirmationMatches("reset agent")) + XCTAssertTrue(model.resetAgentImpactDescription().contains("Recordings, transcriptions")) + XCTAssertTrue(model.resetAgentImpactDescription().contains("license")) + + try engine.resetAgentData() + XCTAssertEqual(calls, 1) + } + + func testAgentDefaultsResetLeavesHotkeysAndOtherPreferencesUntouched() { + let suite = "SettingsTruthTests.agent-reset-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + XCTFail("create isolated defaults suite") + return + } + defaults.set("queued Agent turn", forKey: AgentChatStore.acceptedTurnsDefaultsKey) + defaults.set("attachment metadata", forKey: AgentChatStore.attachmentMetadataDefaultsKey) + defaults.set("ctrl-space", forKey: "codescribe.hotkey") + defaults.set("dictation preference", forKey: "codescribe.dictation") + + AppRelaunch.clearAgentDefaults(defaults: defaults) + + XCTAssertNil(defaults.object(forKey: AgentChatStore.acceptedTurnsDefaultsKey)) + XCTAssertNil(defaults.object(forKey: AgentChatStore.attachmentMetadataDefaultsKey)) + XCTAssertEqual(defaults.string(forKey: "codescribe.hotkey"), "ctrl-space") + XCTAssertEqual(defaults.string(forKey: "codescribe.dictation"), "dictation preference") + defaults.removePersistentDomain(forName: suite) + } + + func testOnlyPostMutationAgentResetErrorsRequireRelaunch() { + XCTAssertFalse(agentResetFailureRequiresRelaunch("failed to prepare Agent Trash destination")) + XCTAssertTrue( + agentResetFailureRequiresRelaunch( + "CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED: failed to remove Agent secret" + ) + ) + } + func testOnlyPostDestructiveResetErrorsRequireRelaunch() { XCTAssertFalse(resetFailureRequiresRelaunch("failed to prepare Trash destination")) XCTAssertTrue( diff --git a/scripts/build-app.sh b/scripts/build-app.sh index e49c6596..d4445cbc 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -22,6 +22,7 @@ # Env toggles: # SKIP_XCODEBUILD=1 stop after xcodegen (verifies stages 1-4 without Xcode) # CODE_SIGNING_ALLOWED=YES|NO passed through to xcodebuild (default NO) +# CODESCRIBE_EMBEDDER_BUNDLE_SOURCE=/path/to/model explicit MiniLM resource source set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -59,6 +60,44 @@ if [ "${SKIP_XCODEBUILD:-0}" != "1" ]; then require swiftc "install Xcode command line tools: xcode-select --install" fi +resolve_embedder_source() { + local explicit="${CODESCRIBE_EMBEDDER_BUNDLE_SOURCE:-${CODESCRIBE_EMBEDDER_PATH:-}}" + if [[ -n "$explicit" && -f "$explicit/config.json" && -f "$explicit/tokenizer.json" && -f "$explicit/model.safetensors" ]]; then + printf '%s\n' "$explicit" + return 0 + fi + + local repo="${CODESCRIBE_EMBEDDER_REPO:-sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2}" + local repo_dir="models--${repo//\//--}" + local cache_roots=() + [[ -n "${CODESCRIBE_HF_CACHE:-}" ]] && cache_roots+=("$CODESCRIBE_HF_CACHE") + [[ -n "${HUGGINGFACE_HUB_CACHE:-}" ]] && cache_roots+=("$HUGGINGFACE_HUB_CACHE") + [[ -n "${HF_HUB_CACHE:-}" ]] && cache_roots+=("$HF_HUB_CACHE") + [[ -n "${HF_HOME:-}" ]] && cache_roots+=("$HF_HOME/hub") + cache_roots+=("$HOME/.cache/huggingface/hub") + cache_roots+=("$HOME/.codescribe/embeddings" "$HOME/.codescribe/embeddings/hub") + + local cache snapshot + for cache in "${cache_roots[@]}"; do + [[ -d "$cache/$repo_dir/snapshots" ]] || continue + for snapshot in "$cache/$repo_dir/snapshots"/*; do + if [[ -f "$snapshot/config.json" && -f "$snapshot/tokenizer.json" && -f "$snapshot/model.safetensors" ]]; then + printf '%s\n' "$snapshot" + return 0 + fi + done + done + return 1 +} + +EMBEDDER_RUNTIME_SOURCE="" +if [[ "${SKIP_XCODEBUILD:-0}" != "1" && "${CODESCRIBE_EMBED_EMBEDDER:-0}" != "1" ]]; then + if ! EMBEDDER_RUNTIME_SOURCE="$(resolve_embedder_source)"; then + echo "error: MiniLM runtime resource not found; run 'make download-embedder' or set CODESCRIBE_EMBEDDER_BUNDLE_SOURCE" >&2 + exit 1 + fi +fi + SCHEME="Codescribe" BRIDGE_DIR="macos/Codescribe/Bridge" DYLIB="$TARGET_DIR/libcodescribe_ffi.dylib" @@ -167,6 +206,17 @@ mkdir -p "$FRAMEWORKS" "$MACOS_DIR" cp "$DYLIB" "$FRAMEWORKS/" cp "$STT_SIDECAR_BIN" "$MACOS_DIR/codescribe-stt-sidecar" chmod 755 "$MACOS_DIR/codescribe-stt-sidecar" +if [[ -n "$EMBEDDER_RUNTIME_SOURCE" ]]; then + EMBEDDER_BUNDLE_DIR="$APP/Contents/Resources/models/embedder" + mkdir -p "$EMBEDDER_BUNDLE_DIR" + cp -L "$EMBEDDER_RUNTIME_SOURCE/config.json" "$EMBEDDER_BUNDLE_DIR/config.json" + cp -L "$EMBEDDER_RUNTIME_SOURCE/tokenizer.json" "$EMBEDDER_BUNDLE_DIR/tokenizer.json" + cp -L "$EMBEDDER_RUNTIME_SOURCE/model.safetensors" "$EMBEDDER_BUNDLE_DIR/model.safetensors" + chmod 644 "$EMBEDDER_BUNDLE_DIR/config.json" "$EMBEDDER_BUNDLE_DIR/tokenizer.json" "$EMBEDDER_BUNDLE_DIR/model.safetensors" + echo " MiniLM runtime resource bundled from HF/local model directory." +else + echo " MiniLM is compiled into the binary by explicit CODESCRIBE_EMBED_EMBEDDER=1." +fi STT_BRIDGE_BUNDLED=0 # Same host-triple pin as Makefile ENGINE_BRIDGE_TARGET (W0-B / S-1): avoid # inheriting the builder's macosxN.0 so bundled bridges match CI/dev hosts. diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index 5942563a..742411e1 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -21,9 +21,9 @@ APP_PATH="$ROOT_DIR/macos/build/Build/Products/Release/${APP_NAME}.app" SIGN=0 NOTARIZE=0 NO_EMBED=0 -# Public daily release is slim: Silero VAD + MiniLM only. Whisper resolves at -# runtime (HF cache / ~/.codescribe/models / Settings download). Embed Whisper -# only via --embed-whisper (curiosity / offline / experimental fat SKU). +# Public daily release keeps Silero in the dylib and packages MiniLM as a signed +# runtime resource. Whisper resolves from cache / Settings download. Embed +# Whisper only via --embed-whisper (curiosity / offline / experimental fat SKU). EMBED_WHISPER=0 EMBED_WHISPER_EXPLICIT=0 DMG_SUFFIX="" @@ -32,7 +32,8 @@ usage() { cat < - - + + @@ -20,12 +20,12 @@ com.apple.security.device.audio-input - + com.apple.security.cs.allow-unsigned-executable-memory - + com.apple.security.cs.allow-dyld-environment-variables diff --git a/scripts/keychain-doctor.sh b/scripts/keychain-doctor.sh new file mode 100755 index 00000000..01722d1a --- /dev/null +++ b/scripts/keychain-doctor.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# ============================================================================ +# keychain-doctor.sh — READ-ONLY diagnosis of the macOS user keychain domain +# ============================================================================ +# Born from the same 2026-08-15 P0 as scripts/lib/keychain-session.sh: a +# release left its ephemeral signing keychain in the user search list and as +# the default keychain, then the directory holding it was wiped. Every later +# keychain access — including Codescribe's own — prompted the operator to +# unlock a file that no longer existed, with a password that could not work. +# +# This tool NEVER mutates. It runs `security list-keychains -d user` and +# `security default-keychain -d user` (both read-only forms — no `-s`), grades +# what it finds, and prints the exact recovery command line derived from the +# ACTUAL current list. It deliberately does not offer to run that line: fixing +# a poisoned keychain domain is an operator button, not an agent's. +# +# Note the recovery is *derived*, not canned. "Just set login.keychain-db" is +# wrong for any operator who legitimately keeps a second keychain in the search +# list — it would silently delete their entry. The doctor prints the surviving +# entries, in order, exactly as they should be re-installed. +# +# scripts/keychain-doctor.sh → human report +# scripts/keychain-doctor.sh --quiet → exit code only +# +# Exit: 0 healthy · 1 stale/missing paths found · 2 could not read the domain +# +# Contract tests: scripts/tests/keychain-session-test.sh +# ============================================================================ +set -uo pipefail + +SECURITY_BIN="${KEYCHAIN_SESSION_SECURITY_BIN:-/usr/bin/security}" +QUIET=0 +[[ "${1:-}" == "--quiet" ]] && QUIET=1 + +say() { (( QUIET )) || printf '%s\n' "$*"; } + +unquote() { + local line="$1" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + line="${line#\"}" + line="${line%\"}" + printf '%s' "$line" +} + +command -v "$SECURITY_BIN" >/dev/null 2>&1 || { + printf 'keychain-doctor: %s is not executable\n' "$SECURITY_BIN" >&2 + exit 2 +} + +# A keychain is "resident" when it lives where the operator's keychains live. +# Anything else in the user search list came from a build: a release staging +# directory, $RUNNER_TEMP, /private/tmp. Existing on disk does NOT make it +# healthy — the 2026-08-15 prompt fired against a keychain whose file was +# perfectly intact, because a release was holding it open as the default. +is_resident() { + case "$1" in + "$HOME/Library/Keychains/"*) return 0 ;; + /Library/Keychains/*) return 0 ;; + /System/Library/Keychains/*) return 0 ;; + *) return 1 ;; + esac +} + +declare -a entries=() resident=() stale=() foreign=() +while IFS= read -r raw; do + entry="$(unquote "$raw")" + [[ -n "$entry" ]] || continue + entries+=("$entry") + if [[ ! -e "$entry" ]]; then + stale+=("$entry") + elif is_resident "$entry"; then + resident+=("$entry") + else + foreign+=("$entry") + fi +done < <("$SECURITY_BIN" list-keychains -d user 2>/dev/null) + +(( ${#entries[@]} > 0 )) || { + printf 'keychain-doctor: user search list is empty or unreadable\n' >&2 + exit 2 +} + +default_raw="$("$SECURITY_BIN" default-keychain -d user 2>/dev/null | head -n1)" +default="$(unquote "$default_raw")" +default_grade=ok +if [[ -n "$default" && ! -e "$default" ]]; then + default_grade=STALE +elif [[ -n "$default" ]] && ! is_resident "$default"; then + default_grade=HIJACKED +fi + +say "=== user keychain search list (${#entries[@]} entr$( ((${#entries[@]}==1)) && echo y || echo ies)) ===" +for entry in "${entries[@]}"; do + if [[ ! -e "$entry" ]]; then + say " STALE $entry (no such file)" + elif is_resident "$entry"; then + say " ok $entry" + else + say " FOREIGN $entry (exists, but lives outside the operator's keychain directory — a build left it here)" + fi +done +say "" +say "=== default keychain ===" +case "$default_grade" in + STALE) say " STALE $default (no such file)" ;; + HIJACKED) say " HIJACKED $default" ;; + *) say " ok ${default:-}" ;; +esac + +if (( ${#stale[@]} == 0 && ${#foreign[@]} == 0 )) && [[ "$default_grade" == "ok" ]]; then + say "" + say "keychain-doctor: healthy — the search list holds only the operator's own" + say "keychains and the default keychain is one of them." + exit 0 +fi + +if [[ "$default_grade" == "HIJACKED" ]]; then + say "" + say " The DEFAULT keychain is a build keychain. This is the state that makes" + say " unrelated apps pop \" wants to use the '' keychain\": every" + say " process in this login session resolves to it first, and its password is" + say " a value generated inside the release — no human knows it. If a release" + say " is running right now, that is the cause and it will end with the run;" + say " if none is, the run died without giving the domain back." +fi + +# The recovery is derived from what is actually here, not canned. Hardcoding +# "just set login.keychain-db" would silently delete the entry of any operator +# who legitimately keeps a second keychain. +replacement="" +if (( ${#resident[@]} > 0 )); then + replacement="${resident[0]}" +elif [[ -e "${HOME}/Library/Keychains/login.keychain-db" ]]; then + replacement="${HOME}/Library/Keychains/login.keychain-db" +fi + +say "" +say "=== recovery (OPERATOR RUNS THIS — the doctor does not) ===" +say " Do NOT run this while a release is signing; it would pull the keychain" +say " out from under it. Check first: pgrep -fl 'release|codesign|notarytool'" +say "" +if (( ${#resident[@]} == 0 )); then + say " Nothing of the operator's own survives in the list. Restore the login keychain:" + say " security list-keychains -d user -s \"\$HOME/Library/Keychains/login.keychain-db\"" + say " security default-keychain -d user -s \"\$HOME/Library/Keychains/login.keychain-db\"" +else + printf -v args '%q ' "${resident[@]}" + say " Re-install only the operator's own entries, in their current order:" + say " security list-keychains -d user -s ${args% }" + [[ "$default_grade" == "ok" ]] || \ + say " security default-keychain -d user -s $(printf '%q' "$replacement")" +fi +say " security unlock-keychain \"\$HOME/Library/Keychains/login.keychain-db\"" +say "" +say " Cause is a release/signing run that borrowed the user keychain domain." +say " scripts/lib/keychain-session.sh is the hardened path: it never takes the" +say " default keychain, and it unlists before deleting. Anything still" +say " hand-rolling 'security list-keychains -s' should move onto it." + +exit 1 diff --git a/scripts/lib/keychain-session.sh b/scripts/lib/keychain-session.sh new file mode 100755 index 00000000..1afbe4b1 --- /dev/null +++ b/scripts/lib/keychain-session.sh @@ -0,0 +1,471 @@ +#!/usr/bin/env bash +# ============================================================================ +# keychain-session.sh — an ephemeral signing keychain that always gives the +# operator's search list back +# ============================================================================ +# BORN FROM (2026-08-15, P0): a normal Codescribe run popped +# +# "Codescribe wants to use the 'Vibecrafted-signing' keychain" +# +# and the login password did not open it, because it was not the login +# keychain. The host's user domain looked like this: +# +# search list: "/private/tmp/vibecrafted-release-3.7.1.rki1gq/.../Vibecrafted-signing.keychain-db" +# "/Users//Library/Keychains/login.keychain-db" +# default: "/private/tmp/vibecrafted-release-3.7.1.rki1gq/.../Vibecrafted-signing.keychain-db" +# +# A release had prepended its ephemeral signing keychain to the *user* search +# list and made it the *default* keychain. Measured at the time: the keychain +# file still existed and the release (`build-vibecrafted-release.sh`, pid +# 66496) was still running. So this was not only a cleanup bug — the host was +# poisoned WHILE the release was healthy and mid-flight, and it would have +# stayed poisoned for the whole run. +# +# FOUR FAILURES PRODUCED IT, and this file exists to make all four impossible: +# +# 0. Taking the login session's default keychain. `security default-keychain +# -d user -s` is global to the login session, not scoped to the release +# shell. Every other process — Codescribe included — then resolves to the +# release's keychain first and prompts for a uuidgen password nobody has. +# Signing does not need it: import, set-key-partition-list, find-identity +# and codesign all take the keychain path explicitly. => We do NOT set the +# default keychain unless KEYCHAIN_SESSION_SET_DEFAULT=1 says so, and the +# restore path below exists for domains an older run already took over. +# +# 1. "delete-keychain also unlists it." It does — but only while the file is +# still there. Put the ephemeral keychain under `mktemp -d` (which is what +# the release did) and the directory can vanish first; `delete-keychain` +# then fails, the `|| true` swallows it, and the search-list entry is +# immortal. => We ALWAYS unlist explicitly, and we unlist BEFORE deleting. +# +# 2. `trap cleanup EXIT` alone. Bash runs an EXIT trap for normal and error +# exits, but a Ctrl-C or a SIGTERM during a 30-minute notarization wait can +# tear the shell down without it. => We trap EXIT INT TERM HUP, and the +# handler is idempotent so double delivery is harmless. +# +# 3. Snapshot-and-restore under concurrency. Naive restore is itself a +# contamination source: +# A snapshots [login] -> list [tempA, login] +# B snapshots [tempA, login] -> list [tempB, tempA, login] +# A restores its snapshot -> list [login] (tempB dropped, B breaks) +# B restores its snapshot -> list [tempA, login] (tempA RESURRECTED, dead path) +# => We never write a remembered list back. Restore means "read the current +# list, remove exactly the entry this session created, write the rest." +# In a single run that reproduces the prior list exactly; under concurrency +# nobody clobbers anybody. The snapshot is kept for the default-keychain +# decision and for the doctor's forensics, not as a thing to replay. +# +# The snapshot is stored as one NUL-terminated path per record — never as a +# shell-concatenated string. The predecessor did +# existing="$(security list-keychains -d user | tr -d '"' | tr '\n' ' ')" +# security list-keychains -d user -s "$TEMP" $existing +# which splits any keychain path containing a space into two nonexistent +# entries, and strips every space out of the default keychain path besides. +# +# Usage (sourceable — the release path): +# . scripts/lib/keychain-session.sh +# keychain_session_begin codescribe-signing # arms the traps itself +# security import ... -k "$KEYCHAIN_SESSION_PATH" ... +# codesign --keychain "$KEYCHAIN_SESSION_PATH" ... +# keychain_session_end # optional; traps cover it +# +# Usage (executable — for CI step boundaries, where each `run:` block is its +# own shell and traps cannot span them): +# scripts/lib/keychain-session.sh begin