From 5ee4d1f3b92db17668051940fb421e53f1bd8419 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 01:54:38 -0500 Subject: [PATCH 01/19] build: fast daily profiles + sccache groundwork - dev: line-tables-only debuginfo, deps at opt-level 2 (compiled once, cached) - new `fast` profile: thin LTO + 16 codegen units for daily release-grade builds; shipping keeps fat-LTO `release` untouched - sccache 0.15 installed, RUSTC_WRAPPER=sccache set at user level (verified passthrough-safe with incremental dev builds) Co-Authored-By: Claude Fable 5 --- src-tauri/Cargo.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 029a087..2a2179d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,6 +9,12 @@ default-run = "echo" [profile.dev] incremental = true # Compile your binary in smaller steps. +debug = "line-tables-only" # Full debuginfo bloats link time; backtraces still work. + +# Dependencies are compiled once and cached — optimize them so debug-run audio +# paths (resampler, FFT, VAD) are usable; the workspace crate stays at opt 0. +[profile.dev.package."*"] +opt-level = 2 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -135,3 +141,11 @@ lto = true codegen-units = 1 strip = true panic = "unwind" + +# Daily release-grade builds: thin LTO + parallel codegen links minutes faster +# at ~equal runtime perf. Shipping artifacts keep using `release` (fat LTO). +# Usage: cargo build --profile fast / npm run tauri build -- -- --profile fast +[profile.fast] +inherits = "release" +lto = "thin" +codegen-units = 16 From 5eb12a4461fa484f2e9f8a1f9a57674d2a6662ac Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 02:02:09 -0500 Subject: [PATCH 02/19] docs+scripts: fast-build workflow (BUILD.md section, build-fast.ps1) Documents the fast cargo profile, sccache, and --no-default-features dev builds; adds a PowerShell wrapper that imports the VS/LLVM env and runs the chosen profile. Authored by Gemini CLI under spec, reviewed; fixed missing --manifest-path on the bare cargo path. Co-Authored-By: Claude Fable 5 --- BUILD.md | 47 ++++++++++++++++++++++++++ scripts/build-fast.ps1 | 75 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 scripts/build-fast.ps1 diff --git a/BUILD.md b/BUILD.md index c3d574e..0917bf8 100644 --- a/BUILD.md +++ b/BUILD.md @@ -109,6 +109,53 @@ cargo tauri dev # or: cargo run --manifest-path src-tauri/Cargo.toml If you switch toolchains, `cargo clean -p whisper-rs-sys` first so CMake reconfigures from scratch (a stale cache keeps the old generator/instance). +## Fast builds (daily workflow) + +For daily development, a `fast` cargo profile is available which provides +release-grade optimization (thin LTO, 16 codegen units) with much faster +compile times than the full `release` profile (fat LTO). + +### Quick start with script + +The easiest way to build on Windows is using the provided PowerShell script +which handles the LLVM environment and Vulkan SDK detection: + +```powershell +# Default: fast build +./scripts/build-fast.ps1 + +# Even faster (skips heavy Intel MKL dependency) +./scripts/build-fast.ps1 -NoDiarization + +# Build and bundle +./scripts/build-fast.ps1 -Bundle + +# Release-grade build +./scripts/build-fast.ps1 -Profile release +``` + +### Manual workflow + +1. **Cargo Profile**: Use `--profile fast` for daily builds. + ```bash + cargo build --profile fast + # or via npm + npm run tauri build -- -- --profile fast + ``` + +2. **sccache**: Speeds up repeated builds by caching dependency crates across + clean builds. + - Prerequisite: `RUSTC_WRAPPER=sccache` must be set as an environment variable. + - Check status: `sccache --show-stats`. + +3. **Skipping Diarization**: To significantly speed up compilation, skip the + heavy Intel MKL dependency by disabling default features: + ```bash + npm run tauri dev -- -- --no-default-features + ``` + *Note: If built without default features, passing `--diarize` at runtime + will error by design.* + ## Linux Install (from source) The raw binary (`src-tauri/target/release/handy`) cannot run standalone — it needs Tauri resource files (tray icons, sounds, VAD model) to be co-located at the expected path. diff --git a/scripts/build-fast.ps1 b/scripts/build-fast.ps1 new file mode 100644 index 0000000..1466f32 --- /dev/null +++ b/scripts/build-fast.ps1 @@ -0,0 +1,75 @@ +param( + [ValidateSet("fast", "release")] + [string]$Profile = "fast", + [switch]$NoDiarization, + [switch]$Bundle +) + +$ErrorActionPreference = "Stop" + +Write-Host "--- Echo: Fast Build Workflow ---" -ForegroundColor Cyan + +# 1. Autodetect Vulkan SDK +$vulkanPath = "C:\VulkanSDK" +if (Test-Path $vulkanPath) { + $latestSdk = Get-ChildItem $vulkanPath -Directory | Sort-Object Name -Descending | Select-Object -First 1 + if ($null -ne $latestSdk) { + $env:VULKAN_SDK = $latestSdk.FullName + Write-Host "Using VULKAN_SDK: $($env:VULKAN_SDK)" + } +} + +# 2. Find and import VsDevCmd.bat +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (Test-Path $vswhere) { + $vsPath = & $vswhere -latest -property installationPath + if ($vsPath) { + $vsDevCmd = Join-Path $vsPath "Common7\Tools\VsDevCmd.bat" + if (Test-Path $vsDevCmd) { + Write-Host "Importing MSVC environment (x64)..." + $tempFile = [IO.Path]::GetTempFileName() + cmd.exe /c "call `"$vsDevCmd`" -arch=x64 > nul && set > `"$tempFile`"" + Get-Content $tempFile | ForEach-Object { + if ($_ -match "^(.*?)=(.*)$") { + $name = $Matches[1] + $value = $Matches[2] + # Avoid overwriting critical PowerShell/System variables + if ($name -notmatch "^(ALLUSERSPROFILE|APPDATA|COMPUTERNAME|ComSpec|CommonProgramFiles|CommonProgramW64|ConfigSetRoot|DriverData|HOMEDRIVE|HOMEPATH|LOCALAPPDATA|LOGONSERVER|NUMBER_OF_PROCESSORS|OS|PATHEXT|PROCESSOR_ARCHITECTURE|PROCESSOR_IDENTIFIER|PROCESSOR_LEVEL|PROCESSOR_REVISION|ProgramData|ProgramFiles|ProgramW64|PSModulePath|PUBLIC|SystemDrive|SystemRoot|TEMP|TMP|USERDOMAIN|USERDOMAIN_ROAMING_PROFILE|USERNAME|USERPROFILE|windir)$") { + [Environment]::SetEnvironmentVariable($name, $value, "Process") + } + } + } + Remove-Item $tempFile + } + } +} + +# 3. Setup LLVM/Ninja Environment +$env:CC = "clang-cl" +$env:CXX = "clang-cl" +$env:CMAKE_GENERATOR = "Ninja" +$env:CMAKE_LINKER_TYPE = "LLD" +$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5" +$env:CXXFLAGS = "/EHsc" +$env:CL = "/EHsc" + +# Clear VS instance vars that break Ninja +$vsVars = @("VSINSTALLDIR", "CMAKE_GENERATOR_INSTANCE", "CMAKE_GENERATOR_PLATFORM", "CMAKE_GENERATOR_TOOLSET") +foreach ($var in $vsVars) { + Remove-Item "env:$var" -ErrorAction SilentlyContinue +} + +# 4. Construct Command +$cargoArgs = @("--profile", $Profile) +if ($NoDiarization) { + $cargoArgs += "--no-default-features" +} + +if ($Bundle) { + $finalCmd = "npm run tauri build -- -- " + ($cargoArgs -join " ") +} else { + $finalCmd = "cargo build --manifest-path `"$PSScriptRoot\..\src-tauri\Cargo.toml`" " + ($cargoArgs -join " ") +} + +Write-Host "Executing: $finalCmd" -ForegroundColor Green +Invoke-Expression $finalCmd From 4bfc9d488ddf469a53e8393104c3d65d84844ba9 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 07:29:06 -0500 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20cargo=20feature=20`diarization`?= =?UTF-8?q?=20(default=20on)=20=E2=80=94=20MKL=20out=20of=20dev=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit speakrs -> ndarray-linalg -> Intel MKL static is the heaviest piece of every build but only serves --diarize. Now optional behind a default-on feature: shipping builds unchanged, daily dev skips it via --no-default-features. Without the feature, --diarize fails fast with a clear rebuild hint. Verified: cargo check both feature sets, 144 lib tests pass without feature. Implemented by Codex CLI under spec (it burned its monthly quota mid-task); warning polish by Claude. Co-Authored-By: Claude Fable 5 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 9 +++++++-- src-tauri/src/file_transcription.rs | 31 ++++++++++++++++++++++++----- src-tauri/src/lib.rs | 1 + src-tauri/src/managers/model.rs | 10 ++++++++++ 5 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bd3df31..3b6bbfc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1595,7 +1595,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "echo" -version = "1.1.6" +version = "1.1.7" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2a2179d..be96154 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -88,7 +88,11 @@ specta = "=2.0.0-rc.22" specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } tauri-plugin-dialog = "2.6" -speakrs = "0.4.2" +speakrs = { version = "0.4.2", optional = true } + +[features] +default = ["diarization"] +diarization = ["dep:speakrs"] [target.'cfg(unix)'.dependencies] signal-hook = "0.3" @@ -144,7 +148,8 @@ panic = "unwind" # Daily release-grade builds: thin LTO + parallel codegen links minutes faster # at ~equal runtime perf. Shipping artifacts keep using `release` (fat LTO). -# Usage: cargo build --profile fast / npm run tauri build -- -- --profile fast +# Usage: cargo build --profile fast (bare cargo only — tauri-cli bundling +# appends --release itself and expects target/release artifacts) [profile.fast] inherits = "release" lto = "thin" diff --git a/src-tauri/src/file_transcription.rs b/src-tauri/src/file_transcription.rs index b40ec9b..43756df 100644 --- a/src-tauri/src/file_transcription.rs +++ b/src-tauri/src/file_transcription.rs @@ -14,6 +14,9 @@ use tauri::{AppHandle, Manager}; /// Sample rate the engine and diarizer both expect (ffmpeg downmixes to this). pub const TARGET_SAMPLE_RATE: u32 = 16_000; +#[cfg(not(feature = "diarization"))] +const DIARIZATION_NOT_INCLUDED: &str = + "diarization not included in this build (rebuild with --features diarization)"; /// `Command` that doesn't flash a console window on Windows. The GUI app runs /// with `windows_subsystem = "windows"` (no console of its own), so spawning a @@ -195,6 +198,13 @@ fn run_engine( speaker_hint: Option, want_words: bool, ) -> Result { + #[cfg(not(feature = "diarization"))] + if diarize { + anyhow::bail!(DIARIZATION_NOT_INCLUDED); + } + #[cfg(not(feature = "diarization"))] + let _ = speaker_hint; + use crate::audio_toolkit::audio::read_wav_samples; use crate::managers::transcription::TranscribeOpts; use crate::progress::{emit_progress, ProgressPhase}; @@ -246,6 +256,7 @@ fn run_engine( println!("[*] Transcribing (this may take a while for large files)..."); emit_progress(app_handle, ProgressPhase::Transcribing, None); + #[cfg_attr(not(feature = "diarization"), allow(unused_mut))] let mut details = manager .transcribe_detailed_with( samples.clone(), @@ -270,12 +281,22 @@ fn run_engine( .ensure_diarization_models() .context("Diarization model setup failed")?; - match crate::diarization::diarize(app_handle, &samples, TARGET_SAMPLE_RATE, speaker_hint) { - Ok(turns) if !turns.is_empty() => details.speakers = Some(turns), - Ok(_) => { - eprintln!("[!] Diarization produced no speaker turns; output has timestamps only.") + #[cfg(feature = "diarization")] + { + match crate::diarization::diarize( + app_handle, + &samples, + TARGET_SAMPLE_RATE, + speaker_hint, + ) { + Ok(turns) if !turns.is_empty() => details.speakers = Some(turns), + Ok(_) => { + eprintln!( + "[!] Diarization produced no speaker turns; output has timestamps only." + ) + } + Err(e) => eprintln!("[!] Diarization failed ({e}); output has timestamps only."), } - Err(e) => eprintln!("[!] Diarization failed ({e}); output has timestamps only."), } if manager.is_cancelled() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a3b0cd..7e60da2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod cli_transcription; mod coach; mod coach_progress; mod commands; +#[cfg(feature = "diarization")] mod diarization; mod file_transcription; mod helpers; diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index b90d1a2..a2f0446 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -91,6 +91,7 @@ pub struct ModelManager { available_models: Mutex>, cancel_flags: Arc>>>, extracting_models: Arc>>, + #[cfg_attr(not(feature = "diarization"), allow(dead_code))] diarization_ensured: Arc, } @@ -1442,6 +1443,7 @@ impl ModelManager { } /// Returns true exactly once per flag lifetime (first caller runs the ensure). + #[cfg_attr(not(feature = "diarization"), allow(dead_code))] fn should_run_diarization_ensure(flag: &std::sync::atomic::AtomicBool) -> bool { flag.compare_exchange( false, @@ -1454,6 +1456,7 @@ impl ModelManager { /// Ensures the diarization models are present (either in the local models/diarization /// directory or in the hf-hub cache). Downloads from HuggingFace if missing. + #[cfg(feature = "diarization")] pub fn ensure_diarization_models(&self) -> Result<()> { if !Self::should_run_diarization_ensure(&self.diarization_ensured) { return Ok(()); @@ -1477,6 +1480,13 @@ impl ModelManager { Ok(()) } + #[cfg(not(feature = "diarization"))] + pub fn ensure_diarization_models(&self) -> Result<()> { + anyhow::bail!( + "diarization not included in this build (rebuild with --features diarization)" + ); + } + pub fn cancel_download(&self, model_id: &str) -> Result<()> { debug!("ModelManager: cancel_download called for: {}", model_id); From 013876c1c89bdbe20b1cd3cd26cbe188795b1164 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 07:29:07 -0500 Subject: [PATCH 04/19] fix: build-fast.ps1 + BUILD.md corrections from agy adversarial review - env-import regex [^=]+ (cmd hidden =C:= vars crashed under -Stop) - Vulkan SDK picked by [version] sort, not alphabetical - direct invocation instead of Invoke-Expression - -Bundle always uses release profile: tauri-cli appends --release itself and the bundler expects target/release artifacts (docs updated to match) Co-Authored-By: Claude Fable 5 --- BUILD.md | 7 ++++--- scripts/build-fast.ps1 | 29 +++++++++++++++++------------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/BUILD.md b/BUILD.md index 0917bf8..e69345f 100644 --- a/BUILD.md +++ b/BUILD.md @@ -138,10 +138,11 @@ which handles the LLVM environment and Vulkan SDK detection: 1. **Cargo Profile**: Use `--profile fast` for daily builds. ```bash - cargo build --profile fast - # or via npm - npm run tauri build -- -- --profile fast + cargo build --manifest-path src-tauri/Cargo.toml --profile fast ``` + Note: `npm run tauri build` always uses the `release` profile — tauri-cli + appends `--release` itself and the bundler expects `target/release` + artifacts, so custom profiles apply to bare `cargo build` only. 2. **sccache**: Speeds up repeated builds by caching dependency crates across clean builds. diff --git a/scripts/build-fast.ps1 b/scripts/build-fast.ps1 index 1466f32..424b8ef 100644 --- a/scripts/build-fast.ps1 +++ b/scripts/build-fast.ps1 @@ -12,7 +12,7 @@ Write-Host "--- Echo: Fast Build Workflow ---" -ForegroundColor Cyan # 1. Autodetect Vulkan SDK $vulkanPath = "C:\VulkanSDK" if (Test-Path $vulkanPath) { - $latestSdk = Get-ChildItem $vulkanPath -Directory | Sort-Object Name -Descending | Select-Object -First 1 + $latestSdk = Get-ChildItem $vulkanPath -Directory | Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 if ($null -ne $latestSdk) { $env:VULKAN_SDK = $latestSdk.FullName Write-Host "Using VULKAN_SDK: $($env:VULKAN_SDK)" @@ -30,7 +30,9 @@ if (Test-Path $vswhere) { $tempFile = [IO.Path]::GetTempFileName() cmd.exe /c "call `"$vsDevCmd`" -arch=x64 > nul && set > `"$tempFile`"" Get-Content $tempFile | ForEach-Object { - if ($_ -match "^(.*?)=(.*)$") { + # [^=]+ (not .*?): cmd emits hidden vars like "=C:=..." whose + # empty name would crash SetEnvironmentVariable under -Stop. + if ($_ -match "^([^=]+)=(.*)$") { $name = $Matches[1] $value = $Matches[2] # Avoid overwriting critical PowerShell/System variables @@ -60,16 +62,19 @@ foreach ($var in $vsVars) { } # 4. Construct Command -$cargoArgs = @("--profile", $Profile) -if ($NoDiarization) { - $cargoArgs += "--no-default-features" -} - if ($Bundle) { - $finalCmd = "npm run tauri build -- -- " + ($cargoArgs -join " ") + # tauri-cli appends --release itself; forwarding a custom --profile would + # conflict and the bundler looks for artifacts in target/release anyway. + # Bundles always build with the shipping `release` profile. + $tauriArgs = @() + if ($NoDiarization) { $tauriArgs = @("--", "--", "--no-default-features") } + Write-Host "Executing: npm run tauri build $($tauriArgs -join ' ')" -ForegroundColor Green + npm run tauri build @tauriArgs } else { - $finalCmd = "cargo build --manifest-path `"$PSScriptRoot\..\src-tauri\Cargo.toml`" " + ($cargoArgs -join " ") + $cargoArgs = @("--profile", $Profile) + if ($NoDiarization) { $cargoArgs += "--no-default-features" } + $manifest = Join-Path $PSScriptRoot "..\src-tauri\Cargo.toml" + Write-Host "Executing: cargo build --manifest-path $manifest $($cargoArgs -join ' ')" -ForegroundColor Green + cargo build --manifest-path $manifest @cargoArgs } - -Write-Host "Executing: $finalCmd" -ForegroundColor Green -Invoke-Expression $finalCmd +exit $LASTEXITCODE From ae7ac1359361f8e2b1842b2f48c455944db64ae1 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 08:33:44 -0500 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20TTS=20v0=20=E2=80=94=20speech=20e?= =?UTF-8?q?ngine=20foundation=20(WinRT=20SpeechSynthesizer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TtsEngine trait + TtsManager (rodio playback, cancel-on-new-speak) with the zero-dependency Windows engine: voice listing/selection and text->WAV via Windows.Media.SpeechSynthesis. Commands tts_list_voices / tts_speak / tts_stop registered through tauri-specta. Non-Windows builds get a clear runtime error. Smoke-tested against real WinRT: 5 voices (incl. RU Irina/Pavel), 148 KB WAV. Run with: cargo test --lib tts -- --ignored --nocapture Scaffold by Gemini CLI under spec; Windows engine implementation, Media_Core feature-gate discovery and smoke test by Claude. First brick of assistant/tutor phases: next engines (Piper/Kokoro via ort) implement the same trait. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + src-tauri/Cargo.toml | 6 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/tts.rs | 25 +++++++ src-tauri/src/lib.rs | 6 ++ src-tauri/src/tts/mod.rs | 128 ++++++++++++++++++++++++++++++++++ src-tauri/src/tts/windows.rs | 72 +++++++++++++++++++ 7 files changed, 241 insertions(+) create mode 100644 src-tauri/src/commands/tts.rs create mode 100644 src-tauri/src/tts/mod.rs create mode 100644 src-tauri/src/tts/windows.rs diff --git a/.gitignore b/.gitignore index bfa3275..42edecd 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ docs/superpowers/ # Local coding-agent scaffolding (Gemini CLI config + plan/notes copies) .gemini/ agent-work/ + +# Serena MCP project artifacts (created by delegate agents) +.serena/ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index be96154..b70fa83 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -112,6 +112,12 @@ windows = { version = "0.61.3", features = [ "Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Console", + "Media", + "Media_Core", + "Media_SpeechSynthesis", + "Storage_Streams", + "Foundation", + "Foundation_Collections", ] } winreg = "0.55" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 56713b4..db67a65 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod history; pub mod models; pub mod transcribe; pub mod transcription; +pub mod tts; use crate::settings::{get_settings, write_settings, AppSettings, LogLevel}; use crate::utils::cancel_current_operation; diff --git a/src-tauri/src/commands/tts.rs b/src-tauri/src/commands/tts.rs new file mode 100644 index 0000000..f3c172f --- /dev/null +++ b/src-tauri/src/commands/tts.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; +use tauri::State; +use crate::tts::{TtsManager, VoiceInfo}; + +#[tauri::command] +#[specta::specta] +pub fn tts_list_voices(tts_manager: State>) -> Result, String> { + tts_manager.list_voices() +} + +#[tauri::command] +#[specta::specta] +pub fn tts_speak( + tts_manager: State>, + text: String, + voice_id: Option, +) -> Result<(), String> { + tts_manager.speak(text, voice_id) +} + +#[tauri::command] +#[specta::specta] +pub fn tts_stop(tts_manager: State>) -> Result<(), String> { + tts_manager.stop() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e60da2..6917abd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod shortcut; mod transcript_format; mod transcription_coordinator; mod translate; +mod tts; mod utils; mod voice_commands; @@ -163,6 +164,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { ); let history_manager = Arc::new(HistoryManager::new(app_handle).expect("Failed to initialize history manager")); + let tts_manager = Arc::new(crate::tts::TtsManager::new()); // Apply accelerator preferences before any model loads managers::transcription::apply_accelerator_settings(app_handle); @@ -172,6 +174,7 @@ fn initialize_core_logic(app_handle: &AppHandle) { app_handle.manage(model_manager.clone()); app_handle.manage(transcription_manager.clone()); app_handle.manage(history_manager.clone()); + app_handle.manage(tts_manager.clone()); // Note: Shortcuts are NOT initialized here. // The frontend is responsible for calling the `initialize_shortcuts` command @@ -460,6 +463,9 @@ pub fn run(cli_args: CliArgs) { commands::history::update_recording_retention_period, commands::transcribe::transcribe_file_to_string, commands::transcribe::cancel_file_transcription, + commands::tts::tts_list_voices, + commands::tts::tts_speak, + commands::tts::tts_stop, commands::coach::get_coach_dashboard, commands::coach::get_coach_baseline, helpers::clamshell::is_laptop, diff --git a/src-tauri/src/tts/mod.rs b/src-tauri/src/tts/mod.rs new file mode 100644 index 0000000..26e9707 --- /dev/null +++ b/src-tauri/src/tts/mod.rs @@ -0,0 +1,128 @@ +use std::io::Cursor; +use std::sync::{Arc, Mutex}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use rodio::{OutputStreamBuilder, Sink}; + +#[cfg(windows)] +mod windows; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct VoiceInfo { + pub id: String, + pub display_name: String, + pub language: String, +} + +pub trait TtsEngine: Send + Sync { + fn list_voices(&self) -> Result, String>; + fn synthesize(&self, text: &str, voice_id: Option<&str>) -> Result, String>; +} + +pub struct TtsManager { + engine: Option>, + current_sink: Arc>>>, +} + +impl TtsManager { + pub fn new() -> Self { + #[cfg(windows)] + { + Self { + engine: Some(Box::new(windows::WindowsTts::new())), + current_sink: Arc::new(Mutex::new(None)), + } + } + #[cfg(not(windows))] + { + Self { + engine: None, + current_sink: Arc::new(Mutex::new(None)), + } + } + } + + pub fn list_voices(&self) -> Result, String> { + self.engine + .as_ref() + .ok_or_else(|| "no TTS engine available on this platform".to_string())? + .list_voices() + } + + pub fn speak(&self, text: String, voice_id: Option) -> Result<(), String> { + let engine = self.engine + .as_ref() + .ok_or_else(|| "no TTS engine available on this platform".to_string())?; + + // Stop current playback + self.stop()?; + + let wav_bytes = engine.synthesize(&text, voice_id.as_deref())?; + + let current_sink = self.current_sink.clone(); + + // Playback in a separate thread to not block + std::thread::spawn(move || { + if let Err(e) = Self::play_wav(wav_bytes, current_sink) { + log::error!("Failed to play TTS audio: {}", e); + } + }); + + Ok(()) + } + + pub fn stop(&self) -> Result<(), String> { + let mut sink_lock = self.current_sink.lock().map_err(|e| e.to_string())?; + if let Some(sink) = sink_lock.take() { + sink.stop(); + } + Ok(()) + } + + pub(crate) fn play_wav(wav_bytes: Vec, current_sink: Arc>>>) -> Result<(), Box> { + let cursor = Cursor::new(wav_bytes); + + let stream_builder = OutputStreamBuilder::from_default_device()?; + let stream_handle = stream_builder.open_stream()?; + let mixer = stream_handle.mixer(); + + // rodio::play in this fork handles decoding if passed a Read + Seek + let sink = Arc::new(rodio::play(mixer, cursor)?); + + { + let mut sink_lock = current_sink.lock().map_err(|e| e.to_string())?; + *sink_lock = Some(sink.clone()); + } + + sink.sleep_until_end(); + + Ok(()) + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + /// Real WinRT synthesis — needs installed voices, so opt-in only: + /// cargo test --lib tts -- --ignored --nocapture + #[test] + #[ignore] + fn windows_tts_synthesizes_wav() { + let engine = windows::WindowsTts::new(); + + let voices = engine.list_voices().expect("list_voices failed"); + assert!(!voices.is_empty(), "no Windows voices installed"); + println!( + "voices: {:?}", + voices.iter().map(|v| &v.display_name).collect::>() + ); + + let wav = engine + .synthesize("Echo speech engine online. Эхо на связи.", None) + .expect("synthesize failed"); + assert!(wav.len() > 44, "WAV too small: {} bytes", wav.len()); + assert_eq!(&wav[0..4], b"RIFF", "not a WAV container"); + println!("synthesized {} bytes", wav.len()); + } +} diff --git a/src-tauri/src/tts/windows.rs b/src-tauri/src/tts/windows.rs new file mode 100644 index 0000000..119ea69 --- /dev/null +++ b/src-tauri/src/tts/windows.rs @@ -0,0 +1,72 @@ +use windows::core::HSTRING; +use windows::Media::SpeechSynthesis::SpeechSynthesizer; +use windows::Storage::Streams::DataReader; + +use super::{TtsEngine, VoiceInfo}; + +/// TTS via the built-in WinRT synthesizer. A fresh `SpeechSynthesizer` is +/// created per call: construction is cheap and it sidesteps apartment/threading +/// questions for an object held across tauri command threads. +pub struct WindowsTts; + +impl WindowsTts { + pub fn new() -> Self { + Self + } +} + +impl TtsEngine for WindowsTts { + fn list_voices(&self) -> Result, String> { + let voices = SpeechSynthesizer::AllVoices().map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for v in &voices { + out.push(VoiceInfo { + id: v.Id().map_err(|e| e.to_string())?.to_string(), + display_name: v.DisplayName().map_err(|e| e.to_string())?.to_string(), + language: v.Language().map_err(|e| e.to_string())?.to_string(), + }); + } + Ok(out) + } + + fn synthesize(&self, text: &str, voice_id: Option<&str>) -> Result, String> { + let synth = SpeechSynthesizer::new().map_err(|e| e.to_string())?; + + if let Some(id) = voice_id { + let voices = SpeechSynthesizer::AllVoices().map_err(|e| e.to_string())?; + let target = voices + .into_iter() + .find(|v| { + v.Id() + .map(|h| h.to_string() == id) + .unwrap_or(false) + }) + .ok_or_else(|| format!("TTS voice not found: {id}"))?; + synth.SetVoice(&target).map_err(|e| e.to_string())?; + } + + // The synthesizer emits a complete WAV container, which rodio decodes. + let stream = synth + .SynthesizeTextToStreamAsync(&HSTRING::from(text)) + .map_err(|e| e.to_string())? + .get() + .map_err(|e| e.to_string())?; + + let size = stream.Size().map_err(|e| e.to_string())?; + let size: u32 = size + .try_into() + .map_err(|_| "synthesized audio exceeds 4 GiB".to_string())?; + + let input = stream.GetInputStreamAt(0).map_err(|e| e.to_string())?; + let reader = DataReader::CreateDataReader(&input).map_err(|e| e.to_string())?; + reader + .LoadAsync(size) + .map_err(|e| e.to_string())? + .get() + .map_err(|e| e.to_string())?; + + let mut wav = vec![0u8; size as usize]; + reader.ReadBytes(&mut wav).map_err(|e| e.to_string())?; + Ok(wav) + } +} From 11f8e246190cffcba97ff12cad61cfe4d5de7f0a Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 08:34:50 -0500 Subject: [PATCH 06/19] style: cargo fmt on tts module Co-Authored-By: Claude Fable 5 --- src-tauri/src/commands/tts.rs | 2 +- src-tauri/src/tts/mod.rs | 20 ++++++++++++-------- src-tauri/src/tts/windows.rs | 6 +----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands/tts.rs b/src-tauri/src/commands/tts.rs index f3c172f..58392cd 100644 --- a/src-tauri/src/commands/tts.rs +++ b/src-tauri/src/commands/tts.rs @@ -1,6 +1,6 @@ +use crate::tts::{TtsManager, VoiceInfo}; use std::sync::Arc; use tauri::State; -use crate::tts::{TtsManager, VoiceInfo}; #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/tts/mod.rs b/src-tauri/src/tts/mod.rs index 26e9707..2a6b7f6 100644 --- a/src-tauri/src/tts/mod.rs +++ b/src-tauri/src/tts/mod.rs @@ -1,8 +1,8 @@ -use std::io::Cursor; -use std::sync::{Arc, Mutex}; +use rodio::{OutputStreamBuilder, Sink}; use serde::{Deserialize, Serialize}; use specta::Type; -use rodio::{OutputStreamBuilder, Sink}; +use std::io::Cursor; +use std::sync::{Arc, Mutex}; #[cfg(windows)] mod windows; @@ -50,7 +50,8 @@ impl TtsManager { } pub fn speak(&self, text: String, voice_id: Option) -> Result<(), String> { - let engine = self.engine + let engine = self + .engine .as_ref() .ok_or_else(|| "no TTS engine available on this platform".to_string())?; @@ -60,7 +61,7 @@ impl TtsManager { let wav_bytes = engine.synthesize(&text, voice_id.as_deref())?; let current_sink = self.current_sink.clone(); - + // Playback in a separate thread to not block std::thread::spawn(move || { if let Err(e) = Self::play_wav(wav_bytes, current_sink) { @@ -79,7 +80,10 @@ impl TtsManager { Ok(()) } - pub(crate) fn play_wav(wav_bytes: Vec, current_sink: Arc>>>) -> Result<(), Box> { + pub(crate) fn play_wav( + wav_bytes: Vec, + current_sink: Arc>>>, + ) -> Result<(), Box> { let cursor = Cursor::new(wav_bytes); let stream_builder = OutputStreamBuilder::from_default_device()?; @@ -88,12 +92,12 @@ impl TtsManager { // rodio::play in this fork handles decoding if passed a Read + Seek let sink = Arc::new(rodio::play(mixer, cursor)?); - + { let mut sink_lock = current_sink.lock().map_err(|e| e.to_string())?; *sink_lock = Some(sink.clone()); } - + sink.sleep_until_end(); Ok(()) diff --git a/src-tauri/src/tts/windows.rs b/src-tauri/src/tts/windows.rs index 119ea69..3fdf0ce 100644 --- a/src-tauri/src/tts/windows.rs +++ b/src-tauri/src/tts/windows.rs @@ -36,11 +36,7 @@ impl TtsEngine for WindowsTts { let voices = SpeechSynthesizer::AllVoices().map_err(|e| e.to_string())?; let target = voices .into_iter() - .find(|v| { - v.Id() - .map(|h| h.to_string() == id) - .unwrap_or(false) - }) + .find(|v| v.Id().map(|h| h.to_string() == id).unwrap_or(false)) .ok_or_else(|| format!("TTS voice not found: {id}"))?; synth.SetVoice(&target).map_err(|e| e.to_string())?; } From d8c4c011a9ecc4ad23b47fa1f33419c1a7acd992 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 09:09:58 -0500 Subject: [PATCH 07/19] feat(agent-bridge): deps + module skeleton + dev-env helper tiny_http/getrandom (+ureq dev-dep) and the agent_bridge module stubs per plan Task 1; scripts/dev-env.ps1 extracts the BUILD.md toolchain env so any shell (incl. delegate agents) can dot-source it before cargo. Co-Authored-By: Claude Fable 5 --- scripts/dev-env.ps1 | 33 +++++++++++++++++++++++++++ src-tauri/Cargo.lock | 33 +++++++++++++++++++++++++++ src-tauri/Cargo.toml | 3 +++ src-tauri/src/agent_bridge/mod.rs | 8 +++++++ src-tauri/src/agent_bridge/server.rs | 1 + src-tauri/src/agent_bridge/state.rs | 1 + src-tauri/src/agent_bridge/storage.rs | 1 + src-tauri/src/agent_bridge/token.rs | 1 + src-tauri/src/agent_bridge/window.rs | 4 ++++ src-tauri/src/lib.rs | 1 + 10 files changed, 86 insertions(+) create mode 100644 scripts/dev-env.ps1 create mode 100644 src-tauri/src/agent_bridge/mod.rs create mode 100644 src-tauri/src/agent_bridge/server.rs create mode 100644 src-tauri/src/agent_bridge/state.rs create mode 100644 src-tauri/src/agent_bridge/storage.rs create mode 100644 src-tauri/src/agent_bridge/token.rs create mode 100644 src-tauri/src/agent_bridge/window.rs diff --git a/scripts/dev-env.ps1 b/scripts/dev-env.ps1 new file mode 100644 index 0000000..1885140 --- /dev/null +++ b/scripts/dev-env.ps1 @@ -0,0 +1,33 @@ +# Dot-source before any cargo command: `. ./scripts/dev-env.ps1` +# Imports the VS x64 env + LLVM/Ninja toolchain vars (BUILD.md, "Windows release +# build"). Without this, whisper-rs-sys' CMake build breaks on this toolchain. +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (Test-Path $vswhere) { + $vsPath = & $vswhere -latest -property installationPath + $vsDevCmd = Join-Path $vsPath "Common7\Tools\VsDevCmd.bat" + if (Test-Path $vsDevCmd) { + $tmp = [IO.Path]::GetTempFileName() + cmd.exe /c "call `"$vsDevCmd`" -arch=x64 > nul && set > `"$tmp`"" + Get-Content $tmp | ForEach-Object { + # [^=]+ : cmd emits hidden "=C:=..." vars whose empty name would throw + if ($_ -match '^([^=]+)=(.*)$') { + [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') + } + } + Remove-Item $tmp -Force + } +} + +$env:CC = 'clang-cl'; $env:CXX = 'clang-cl' +$env:CMAKE_GENERATOR = 'Ninja' +$env:CMAKE_LINKER_TYPE = 'LLD' +$env:CMAKE_POLICY_VERSION_MINIMUM = '3.5' +$env:CXXFLAGS = '/EHsc'; $env:CL = '/EHsc' +if (Test-Path C:\VulkanSDK) { + $env:VULKAN_SDK = (Get-ChildItem C:\VulkanSDK -Directory | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1).FullName +} +foreach ($v in 'VSINSTALLDIR', 'CMAKE_GENERATOR_INSTANCE', 'CMAKE_GENERATOR_PLATFORM', 'CMAKE_GENERATOR_TOOLSET') { + Remove-Item "env:$v" -ErrorAction SilentlyContinue +} +Write-Host "echo dev env ready (VULKAN_SDK=$env:VULKAN_SDK)" -ForegroundColor DarkGray diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3b6bbfc..02cd543 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -198,6 +198,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -809,6 +815,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clang-sys" version = "1.8.1" @@ -1606,6 +1618,7 @@ dependencies = [ "ferrous-opencc", "flate2", "futures-util", + "getrandom 0.3.4", "gtk", "gtk-layer-shell", "handy-keys", @@ -1648,8 +1661,10 @@ dependencies = [ "tauri-plugin-updater", "tauri-specta", "tempfile", + "tiny_http", "tokio", "transcribe-rs", + "ureq 2.12.1", "vad-rs", "windows 0.61.3", "winreg 0.55.0", @@ -2774,6 +2789,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.3.0" @@ -7239,6 +7260,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b70fa83..3e069f8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -89,6 +89,8 @@ specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } tauri-plugin-dialog = "2.6" speakrs = { version = "0.4.2", optional = true } +tiny_http = "0.12" +getrandom = "0.3" [features] default = ["diarization"] @@ -145,6 +147,7 @@ speakrs = { git = "https://github.com/master5d/speakrs", branch = "progress" } [dev-dependencies] tempfile = "3" +ureq = { version = "2", features = ["json"] } [profile.release] lto = true diff --git a/src-tauri/src/agent_bridge/mod.rs b/src-tauri/src/agent_bridge/mod.rs new file mode 100644 index 0000000..ca973b7 --- /dev/null +++ b/src-tauri/src/agent_bridge/mod.rs @@ -0,0 +1,8 @@ +//! Agent Bridge: localhost HTTP API letting agents ask the user questions +//! through a floating panel and journal the answers locally. +//! Design: docs/superpowers/specs/2026-06-12-agent-bridge-design.md +pub mod server; +pub mod state; +pub mod storage; +pub mod token; +pub mod window; diff --git a/src-tauri/src/agent_bridge/server.rs b/src-tauri/src/agent_bridge/server.rs new file mode 100644 index 0000000..675ee3f --- /dev/null +++ b/src-tauri/src/agent_bridge/server.rs @@ -0,0 +1 @@ +// implemented in plan Task 5 diff --git a/src-tauri/src/agent_bridge/state.rs b/src-tauri/src/agent_bridge/state.rs new file mode 100644 index 0000000..ca3da63 --- /dev/null +++ b/src-tauri/src/agent_bridge/state.rs @@ -0,0 +1 @@ +// implemented in plan Task 4 diff --git a/src-tauri/src/agent_bridge/storage.rs b/src-tauri/src/agent_bridge/storage.rs new file mode 100644 index 0000000..cbd649c --- /dev/null +++ b/src-tauri/src/agent_bridge/storage.rs @@ -0,0 +1 @@ +// implemented in plan Task 2 diff --git a/src-tauri/src/agent_bridge/token.rs b/src-tauri/src/agent_bridge/token.rs new file mode 100644 index 0000000..f71d817 --- /dev/null +++ b/src-tauri/src/agent_bridge/token.rs @@ -0,0 +1 @@ +// implemented in plan Task 3 diff --git a/src-tauri/src/agent_bridge/window.rs b/src-tauri/src/agent_bridge/window.rs new file mode 100644 index 0000000..fcb1289 --- /dev/null +++ b/src-tauri/src/agent_bridge/window.rs @@ -0,0 +1,4 @@ +use tauri::AppHandle; + +/// Stub until plan Task 7 creates the real panel window. +pub fn show_panel(_app: &AppHandle) {} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6917abd..107ff3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod actions; +mod agent_bridge; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] mod apple_intelligence; pub mod audio_toolkit; From e85fa1a4e04f490bdec8bb7607cb326fa49f05b2 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 09:14:14 -0500 Subject: [PATCH 08/19] feat(agent-bridge): question journal storage (separate agent_bridge.db schema) --- src-tauri/src/agent_bridge/storage.rs | 154 +++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/agent_bridge/storage.rs b/src-tauri/src/agent_bridge/storage.rs index cbd649c..ee0b8c2 100644 --- a/src-tauri/src/agent_bridge/storage.rs +++ b/src-tauri/src/agent_bridge/storage.rs @@ -1 +1,153 @@ -// implemented in plan Task 2 +use anyhow::Result; +use rusqlite::Connection; +use std::path::Path; +use std::sync::Mutex; + +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +pub struct QuestionRow { + pub id: i64, + pub source: String, + pub kind: String, + pub question: String, + pub options: Option, + pub answer: Option, + pub status: String, + pub asked_at: i64, + pub answered_at: Option, +} + +pub struct BridgeStore { + conn: Mutex, +} + +const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS agent_questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + kind TEXT NOT NULL, + question TEXT NOT NULL, + options TEXT, + answer TEXT, + status TEXT NOT NULL DEFAULT 'pending', + asked_at INTEGER NOT NULL, + answered_at INTEGER +);"; + +impl BridgeStore { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.execute_batch(SCHEMA)?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch(SCHEMA)?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn insert_question( + &self, + source: &str, + kind: &str, + question: &str, + options_json: Option<&str>, + ) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO agent_questions (source, kind, question, options, asked_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![source, kind, question, options_json, now_ms()], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn mark_answered(&self, id: i64, answer: &str) -> Result<()> { + self.set_status(id, "answered", Some(answer)) + } + pub fn mark_timeout(&self, id: i64) -> Result<()> { + self.set_status(id, "timeout", None) + } + pub fn mark_dismissed(&self, id: i64) -> Result<()> { + self.set_status(id, "dismissed", None) + } + + fn set_status(&self, id: i64, status: &str, answer: Option<&str>) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE agent_questions + SET status = ?2, answer = ?3, answered_at = ?4 WHERE id = ?1", + rusqlite::params![id, status, answer, now_ms()], + )?; + Ok(()) + } + + pub fn list_since(&self, since_ms: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, source, kind, question, options, answer, status, asked_at, answered_at + FROM agent_questions WHERE asked_at >= ?1 ORDER BY id", + )?; + let rows = stmt + .query_map([since_ms], |r| { + Ok(QuestionRow { + id: r.get(0)?, + source: r.get(1)?, + kind: r.get(2)?, + question: r.get(3)?, + options: r.get(4)?, + answer: r.get(5)?, + status: r.get(6)?, + asked_at: r.get(7)?, + answered_at: r.get(8)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + } +} + +pub(crate) fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem() -> BridgeStore { + BridgeStore::open_in_memory().unwrap() + } + + #[test] + fn insert_then_answer_roundtrip() { + let s = mem(); + let id = s + .insert_question("claude", "text", "Deploy?", None) + .unwrap(); + s.mark_answered(id, "yes").unwrap(); + let rows = s.list_since(0).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, "answered"); + assert_eq!(rows[0].answer.as_deref(), Some("yes")); + assert_eq!(rows[0].question, "Deploy?"); + } + + #[test] + fn timeout_and_dismiss_statuses() { + let s = mem(); + let a = s.insert_question("cron", "confirm", "Meds?", None).unwrap(); + let b = s + .insert_question("cron", "choice", "Mood?", Some(r#"["good","bad"]"#)) + .unwrap(); + s.mark_timeout(a).unwrap(); + s.mark_dismissed(b).unwrap(); + let rows = s.list_since(0).unwrap(); + assert_eq!(rows[0].status, "timeout"); + assert_eq!(rows[1].status, "dismissed"); + assert_eq!(rows[1].options.as_deref(), Some(r#"["good","bad"]"#)); + } +} From 95586f29649b6020a5c38c60399a1c512c3ab313 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 09:15:18 -0500 Subject: [PATCH 09/19] feat(agent-bridge): bearer token file --- src-tauri/src/agent_bridge/token.rs | 38 ++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/agent_bridge/token.rs b/src-tauri/src/agent_bridge/token.rs index f71d817..15511c2 100644 --- a/src-tauri/src/agent_bridge/token.rs +++ b/src-tauri/src/agent_bridge/token.rs @@ -1 +1,37 @@ -// implemented in plan Task 3 +use anyhow::{Context, Result}; +use std::path::Path; + +pub const TOKEN_FILE: &str = "agent_bridge_token"; + +/// Loads the bearer token from `/agent_bridge_token`, creating a +/// random one (32 bytes hex) on first run. Agents read the same file. +pub fn load_or_create_token(app_data_dir: &Path) -> Result { + let path = app_data_dir.join(TOKEN_FILE); + if let Ok(existing) = std::fs::read_to_string(&path) { + let trimmed = existing.trim().to_string(); + if !trimmed.is_empty() { + return Ok(trimmed); + } + } + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).context("getrandom failed")?; + let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + std::fs::create_dir_all(app_data_dir)?; + std::fs::write(&path, &token)?; + Ok(token) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_then_reuses_token() { + let dir = tempfile::tempdir().unwrap(); + let t1 = load_or_create_token(dir.path()).unwrap(); + let t2 = load_or_create_token(dir.path()).unwrap(); + assert_eq!(t1, t2); + assert_eq!(t1.len(), 64); // 32 bytes hex + assert!(t1.chars().all(|c| c.is_ascii_hexdigit())); + } +} From 5a12dbd4b73f2081d169d7853b39cd85e0968477 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 09:17:07 -0500 Subject: [PATCH 10/19] feat(agent-bridge): pending-question state machine --- src-tauri/src/agent_bridge/state.rs | 110 +++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/agent_bridge/state.rs b/src-tauri/src/agent_bridge/state.rs index ca3da63..ebea5a7 100644 --- a/src-tauri/src/agent_bridge/state.rs +++ b/src-tauri/src/agent_bridge/state.rs @@ -1 +1,109 @@ -// implemented in plan Task 4 +use std::collections::HashMap; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub enum Outcome { + Answered(String), + Dismissed, + Timeout, +} + +#[derive(Clone)] +pub struct BridgeState { + pending: Arc>>>, + /// Serializes asks so the panel shows one question at a time. + pub ask_serial: Arc>, + /// Number of asks waiting for the serial lock (for the 429 cap). + pub waiting: Arc, +} + +pub struct PendingQuestion { + id: i64, + rx: Receiver, + state: BridgeState, +} + +impl BridgeState { + pub fn new() -> Self { + Self { + pending: Arc::new(Mutex::new(HashMap::new())), + ask_serial: Arc::new(Mutex::new(())), + waiting: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + } + } + + pub fn begin_question(&self, id: i64) -> PendingQuestion { + let (tx, rx) = channel(); + self.pending.lock().unwrap().insert(id, tx); + PendingQuestion { + id, + rx, + state: self.clone(), + } + } + + /// Returns false if the id is unknown (already resolved / timed out). + pub fn resolve(&self, id: i64, outcome: Outcome) -> bool { + if let Some(tx) = self.pending.lock().unwrap().remove(&id) { + let _ = tx.send(outcome); + true + } else { + false + } + } + + /// Id of the currently pending question, if any (used by GET /v1/pending + /// and by the panel on mount). + pub fn pending_id(&self) -> Option { + self.pending.lock().unwrap().keys().next().copied() + } +} + +impl PendingQuestion { + pub fn wait(self, timeout: Duration) -> Outcome { + match self.rx.recv_timeout(timeout) { + Ok(o) => o, + Err(_) => { + self.state.pending.lock().unwrap().remove(&self.id); + Outcome::Timeout + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn answer_resolves_waiting_ask() { + let st = BridgeState::new(); + let pending = st.begin_question(7); + let st2 = st.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + assert!(st2.resolve(7, Outcome::Answered("ok".into()))); + }); + let out = pending.wait(Duration::from_secs(2)); + assert!(matches!(out, Outcome::Answered(a) if a == "ok")); + } + + #[test] + fn timeout_when_nobody_answers() { + let st = BridgeState::new(); + let pending = st.begin_question(1); + let out = pending.wait(Duration::from_millis(50)); + assert!(matches!(out, Outcome::Timeout)); + // resolved/cleaned up: late answer is rejected + assert!(!st.resolve(1, Outcome::Answered("late".into()))); + } + + #[test] + fn resolve_unknown_id_is_false() { + let st = BridgeState::new(); + assert!(!st.resolve(99, Outcome::Dismissed)); + } +} From 279a799e9823de29ece37b62df79e1011de4bb5b Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 09:24:54 -0500 Subject: [PATCH 11/19] feat(agent-bridge): localhost HTTP server (ask/notify/answers, bearer auth) --- src-tauri/src/agent_bridge/server.rs | 324 ++++++++++++++++++++++++++- 1 file changed, 323 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/agent_bridge/server.rs b/src-tauri/src/agent_bridge/server.rs index 675ee3f..e4adb38 100644 --- a/src-tauri/src/agent_bridge/server.rs +++ b/src-tauri/src/agent_bridge/server.rs @@ -1 +1,323 @@ -// implemented in plan Task 5 +use super::state::{BridgeState, Outcome}; +use super::storage::BridgeStore; +use anyhow::Result; +use serde::Deserialize; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use tiny_http::{Header, Method, Response, Server}; + +pub struct ServerConfig { + /// 0 = ephemeral (tests); real default comes from settings (4123). + pub port: u16, + pub token: String, +} + +/// What the UI layer needs to know to show a question. +#[derive(Clone, Debug, serde::Serialize)] +pub struct QuestionEvent { + pub id: i64, + pub kind: String, + pub question: String, + pub options: Vec, + pub timeout_s: u64, + pub speak: bool, + pub source: String, +} + +pub type AskSink = Arc; + +#[derive(Deserialize)] +struct AskBody { + question: String, + #[serde(default = "default_kind")] + kind: String, // "text" | "choice" | "confirm" + #[serde(default)] + options: Vec, + #[serde(default = "default_timeout")] + timeout_s: u64, + #[serde(default)] + speak: bool, + #[serde(default = "default_source")] + source: String, +} +fn default_kind() -> String { + "text".into() +} +fn default_timeout() -> u64 { + 300 +} +fn default_source() -> String { + "unknown".into() +} + +#[derive(Deserialize)] +struct NotifyBody { + message: String, + #[serde(default)] + speak: bool, + #[serde(default = "default_source")] + source: String, +} + +const MAX_BODY: usize = 64 * 1024; +const MAX_WAITING: usize = 10; +const MAX_TIMEOUT_S: u64 = 30 * 60; + +/// Starts the server on 127.0.0.1, returns the bound port. +pub fn start_server( + cfg: ServerConfig, + store: Arc, + state: BridgeState, + sink: AskSink, +) -> Result { + let server = Server::http(("127.0.0.1", cfg.port)) + .map_err(|e| anyhow::anyhow!("agent-bridge bind failed: {e}"))?; + let port = server + .server_addr() + .to_ip() + .map(|a| a.port()) + .unwrap_or(cfg.port); + let token = Arc::new(cfg.token); + std::thread::spawn(move || { + for req in server.incoming_requests() { + let store = store.clone(); + let state = state.clone(); + let sink = sink.clone(); + let token = token.clone(); + std::thread::spawn(move || handle(req, &token, store, state, sink)); + } + }); + Ok(port) +} + +fn json_response(code: u16, body: &serde_json::Value) -> Response>> { + let data = serde_json::to_vec(body).unwrap_or_default(); + Response::from_data(data) + .with_status_code(code) + .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap()) +} + +fn authed(req: &tiny_http::Request, token: &str) -> bool { + req.headers().iter().any(|h| { + h.field + .as_str() + .as_str() + .eq_ignore_ascii_case("authorization") + && h.value.as_str() == format!("Bearer {token}") + }) +} + +fn read_body(req: &mut tiny_http::Request) -> Result { + let mut buf = Vec::new(); + use std::io::Read; + req.as_reader() + .take(MAX_BODY as u64 + 1) + .read_to_end(&mut buf) + .map_err(|e| e.to_string())?; + if buf.len() > MAX_BODY { + return Err("body too large".into()); + } + serde_json::from_slice(&buf).map_err(|e| e.to_string()) +} + +fn handle( + mut req: tiny_http::Request, + token: &str, + store: Arc, + state: BridgeState, + sink: AskSink, +) { + if !authed(&req, token) { + let _ = req.respond(json_response( + 401, + &serde_json::json!({"error": "unauthorized"}), + )); + return; + } + let url = req.url().to_string(); + let method = req.method().clone(); + let resp = match (method, url.as_str()) { + (Method::Post, "/v1/ask") => match read_body::(&mut req) { + Ok(b) => handle_ask(b, &store, &state, &sink), + Err(e) => json_response(400, &serde_json::json!({"error": e})), + }, + (Method::Post, "/v1/notify") => match read_body::(&mut req) { + Ok(b) => { + let _ = store.insert_question(&b.source, "notify", &b.message, None); + sink(QuestionEvent { + id: 0, + kind: "notify".into(), + question: b.message, + options: vec![], + timeout_s: 0, + speak: b.speak, + source: b.source, + }); + json_response(202, &serde_json::json!({"status": "accepted"})) + } + Err(e) => json_response(400, &serde_json::json!({"error": e})), + }, + (Method::Get, u) if u.starts_with("/v1/answers") => { + let since: i64 = u + .split("since=") + .nth(1) + .and_then(|s| s.split('&').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + match store.list_since(since) { + Ok(rows) => json_response(200, &serde_json::to_value(rows).unwrap_or_default()), + Err(e) => json_response(500, &serde_json::json!({"error": e.to_string()})), + } + } + _ => json_response(404, &serde_json::json!({"error": "not found"})), + }; + let _ = req.respond(resp); +} + +fn handle_ask( + b: AskBody, + store: &Arc, + state: &BridgeState, + sink: &AskSink, +) -> Response>> { + if b.question.trim().is_empty() { + return json_response(400, &serde_json::json!({"error": "empty question"})); + } + if state.waiting.load(Ordering::SeqCst) >= MAX_WAITING { + return json_response(429, &serde_json::json!({"error": "ask queue full"})); + } + state.waiting.fetch_add(1, Ordering::SeqCst); + let _serial = state.ask_serial.lock().unwrap(); // one question on screen at a time + state.waiting.fetch_sub(1, Ordering::SeqCst); + + let options_json = if b.options.is_empty() { + None + } else { + Some(serde_json::to_string(&b.options).unwrap_or_default()) + }; + let id = match store.insert_question(&b.source, &b.kind, &b.question, options_json.as_deref()) { + Ok(id) => id, + Err(e) => return json_response(500, &serde_json::json!({"error": e.to_string()})), + }; + let timeout_s = b.timeout_s.clamp(5, MAX_TIMEOUT_S); + let pending = state.begin_question(id); + sink(QuestionEvent { + id, + kind: b.kind.clone(), + question: b.question.clone(), + options: b.options.clone(), + timeout_s, + speak: b.speak, + source: b.source.clone(), + }); + let outcome = pending.wait(Duration::from_secs(timeout_s)); + let (status, answer) = match &outcome { + Outcome::Answered(a) => { + let _ = store.mark_answered(id, a); + ("answered", Some(a.clone())) + } + Outcome::Dismissed => { + let _ = store.mark_dismissed(id); + ("dismissed", None) + } + Outcome::Timeout => { + let _ = store.mark_timeout(id); + ("timeout", None) + } + }; + json_response( + 200, + &serde_json::json!({"id": id, "status": status, "answer": answer}), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::time::Duration; + + fn boot(answer_with: Option<&'static str>) -> (u16, Arc) { + let store = Arc::new(BridgeStore::open_in_memory().unwrap()); + let state = BridgeState::new(); + let st = state.clone(); + let sink: AskSink = Arc::new(move |ev: QuestionEvent| { + if let Some(ans) = answer_with { + let st = st.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(30)); + st.resolve(ev.id, Outcome::Answered(ans.to_string())); + }); + } + }); + let port = start_server( + ServerConfig { + port: 0, + token: "secret".into(), + }, + store.clone(), + state, + sink, + ) + .unwrap(); + (port, store) + } + + #[test] + fn rejects_missing_token() { + let (port, _) = boot(None); + let err = ureq::post(&format!("http://127.0.0.1:{port}/v1/ask")) + .send_json(ureq::json!({"question": "hi"})) + .unwrap_err(); + match err { + ureq::Error::Status(code, _) => assert_eq!(code, 401), + other => panic!("expected status error, got {other:?}"), + } + } + + #[test] + fn ask_roundtrip_answered() { + let (port, store) = boot(Some("да")); + let resp: serde_json::Value = ureq::post(&format!("http://127.0.0.1:{port}/v1/ask")) + .set("Authorization", "Bearer secret") + .send_json(ureq::json!({"question": "Готово?", "kind": "confirm", "timeout_s": 5})) + .unwrap() + .into_json() + .unwrap(); + assert_eq!(resp["status"], "answered"); + assert_eq!(resp["answer"], "да"); + assert_eq!(store.list_since(0).unwrap()[0].status, "answered"); + } + + #[test] + fn ask_times_out() { + let (port, store) = boot(None); + let resp: serde_json::Value = ureq::post(&format!("http://127.0.0.1:{port}/v1/ask")) + .set("Authorization", "Bearer secret") + .send_json(ureq::json!({"question": "Эй?", "timeout_s": 1})) + .unwrap() + .into_json() + .unwrap(); + assert_eq!(resp["status"], "timeout"); + assert_eq!(store.list_since(0).unwrap()[0].status, "timeout"); + } + + #[test] + fn notify_and_answers_endpoints() { + let (port, _) = boot(None); + let r = ureq::post(&format!("http://127.0.0.1:{port}/v1/notify")) + .set("Authorization", "Bearer secret") + .send_json(ureq::json!({"message": "done"})) + .unwrap(); + assert_eq!(r.status(), 202); + let rows: serde_json::Value = + ureq::get(&format!("http://127.0.0.1:{port}/v1/answers?since=0")) + .set("Authorization", "Bearer secret") + .call() + .unwrap() + .into_json() + .unwrap(); + assert!(rows.as_array().unwrap().len() >= 1); // notify journaled as kind=notify + } +} From 458accff842844662cdbfea786b202c8769a277e Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 09:31:09 -0500 Subject: [PATCH 12/19] fix(agent-bridge): harden state/storage/token per agy quality review - wait/resolve race: late answer is collected via try_recv instead of lost - Drop impl on PendingQuestion prevents sender leaks on unwind - poisoned-lock recovery (lock_ok) so the bridge never wedges - set_status errors on unknown id (was silent Ok) - pending_id returns oldest (min), not arbitrary HashMap key - token file written owner-only (0600) on Unix Co-Authored-By: Claude Fable 5 --- src-tauri/src/agent_bridge/state.rs | 33 +++++++++++++++++++++------ src-tauri/src/agent_bridge/storage.rs | 13 +++++++++-- src-tauri/src/agent_bridge/token.rs | 23 ++++++++++++++++++- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/agent_bridge/state.rs b/src-tauri/src/agent_bridge/state.rs index ebea5a7..0d852c3 100644 --- a/src-tauri/src/agent_bridge/state.rs +++ b/src-tauri/src/agent_bridge/state.rs @@ -36,7 +36,8 @@ impl BridgeState { pub fn begin_question(&self, id: i64) -> PendingQuestion { let (tx, rx) = channel(); - self.pending.lock().unwrap().insert(id, tx); + let prev = lock_ok(&self.pending).insert(id, tx); + debug_assert!(prev.is_none(), "duplicate question id {id}"); PendingQuestion { id, rx, @@ -46,7 +47,7 @@ impl BridgeState { /// Returns false if the id is unknown (already resolved / timed out). pub fn resolve(&self, id: i64, outcome: Outcome) -> bool { - if let Some(tx) = self.pending.lock().unwrap().remove(&id) { + if let Some(tx) = lock_ok(&self.pending).remove(&id) { let _ = tx.send(outcome); true } else { @@ -54,25 +55,43 @@ impl BridgeState { } } - /// Id of the currently pending question, if any (used by GET /v1/pending - /// and by the panel on mount). + /// Id of the oldest pending question, if any (used by the panel on mount). pub fn pending_id(&self) -> Option { - self.pending.lock().unwrap().keys().next().copied() + lock_ok(&self.pending).keys().min().copied() } } +/// Recover the guard even if a previous holder panicked — the bridge must not +/// wedge on a poisoned lock. +fn lock_ok(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + impl PendingQuestion { pub fn wait(self, timeout: Duration) -> Outcome { match self.rx.recv_timeout(timeout) { Ok(o) => o, Err(_) => { - self.state.pending.lock().unwrap().remove(&self.id); - Outcome::Timeout + let removed = lock_ok(&self.state.pending).remove(&self.id); + if removed.is_none() { + // resolve() won the race during our timeout and already + // sent the outcome — collect it instead of dropping it. + self.rx.try_recv().unwrap_or(Outcome::Timeout) + } else { + Outcome::Timeout + } } } } } +impl Drop for PendingQuestion { + fn drop(&mut self) { + // Insurance against sender leaks if a holder unwinds without wait(). + lock_ok(&self.state.pending).remove(&self.id); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/agent_bridge/storage.rs b/src-tauri/src/agent_bridge/storage.rs index ee0b8c2..4568da1 100644 --- a/src-tauri/src/agent_bridge/storage.rs +++ b/src-tauri/src/agent_bridge/storage.rs @@ -76,12 +76,15 @@ impl BridgeStore { } fn set_status(&self, id: i64, status: &str, answer: Option<&str>) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( + let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner()); + let affected = conn.execute( "UPDATE agent_questions SET status = ?2, answer = ?3, answered_at = ?4 WHERE id = ?1", rusqlite::params![id, status, answer, now_ms()], )?; + if affected == 0 { + anyhow::bail!("question {id} not found"); + } Ok(()) } @@ -136,6 +139,12 @@ mod tests { assert_eq!(rows[0].question, "Deploy?"); } + #[test] + fn set_status_unknown_id_errors() { + let s = mem(); + assert!(s.mark_answered(999, "x").is_err()); + } + #[test] fn timeout_and_dismiss_statuses() { let s = mem(); diff --git a/src-tauri/src/agent_bridge/token.rs b/src-tauri/src/agent_bridge/token.rs index 15511c2..0aeb4b0 100644 --- a/src-tauri/src/agent_bridge/token.rs +++ b/src-tauri/src/agent_bridge/token.rs @@ -17,10 +17,31 @@ pub fn load_or_create_token(app_data_dir: &Path) -> Result { getrandom::fill(&mut bytes).context("getrandom failed")?; let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); std::fs::create_dir_all(app_data_dir)?; - std::fs::write(&path, &token)?; + write_owner_only(&path, &token)?; Ok(token) } +/// Owner-only (0600) on Unix; on Windows the per-user %APPDATA% ACL applies. +#[cfg(unix)] +fn write_owner_only(path: &Path, contents: &str) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(contents.as_bytes())?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_owner_only(path: &Path, contents: &str) -> Result<()> { + std::fs::write(path, contents)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From 02bc489b2550ad10b994320cd5d2fd464839e97f Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 10:03:40 -0500 Subject: [PATCH 13/19] =?UTF-8?q?feat(agent-bridge):=20tauri=20wiring=20?= =?UTF-8?q?=E2=80=94=20settings,=20commands,=20TTS=20speak,=20cold-window?= =?UTF-8?q?=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings: agent_bridge_enabled (default on) + agent_bridge_port (4123) - lib.rs init: token + agent_bridge.db + server start; sink emits agent-question, shows the panel and speaks the question via TtsManager when speak:true - commands agent_bridge_answer/dismiss/answers/current (specta, bindings regenerated) - cold-window race: QuestionEvent moved to state with current-question tracking; a freshly created panel pulls the active question on mount; panel always hides on answer/dismiss even after server-side timeout - ask_serial recovers from poisoned lock; open_in_memory test-gated Co-Authored-By: Claude Fable 5 --- src-tauri/src/agent_bridge/server.rs | 26 +++++----- src-tauri/src/agent_bridge/state.rs | 38 +++++++++++++-- src-tauri/src/agent_bridge/storage.rs | 1 + src-tauri/src/agent_bridge/window.rs | 5 +- src-tauri/src/commands/agent_bridge.rs | 54 +++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 59 +++++++++++++++++++++++ src-tauri/src/settings.rs | 14 ++++++ src/bindings.ts | 66 +++++++++++++++++++++++++- 9 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 src-tauri/src/commands/agent_bridge.rs diff --git a/src-tauri/src/agent_bridge/server.rs b/src-tauri/src/agent_bridge/server.rs index e4adb38..cf39ff0 100644 --- a/src-tauri/src/agent_bridge/server.rs +++ b/src-tauri/src/agent_bridge/server.rs @@ -1,3 +1,4 @@ +pub use super::state::QuestionEvent; use super::state::{BridgeState, Outcome}; use super::storage::BridgeStore; use anyhow::Result; @@ -13,18 +14,6 @@ pub struct ServerConfig { pub token: String, } -/// What the UI layer needs to know to show a question. -#[derive(Clone, Debug, serde::Serialize)] -pub struct QuestionEvent { - pub id: i64, - pub kind: String, - pub question: String, - pub options: Vec, - pub timeout_s: u64, - pub speak: bool, - pub source: String, -} - pub type AskSink = Arc; #[derive(Deserialize)] @@ -188,7 +177,9 @@ fn handle_ask( return json_response(429, &serde_json::json!({"error": "ask queue full"})); } state.waiting.fetch_add(1, Ordering::SeqCst); - let _serial = state.ask_serial.lock().unwrap(); // one question on screen at a time + // One question on screen at a time; recover from poisoning so a panicked + // handler can't wedge every future ask. + let _serial = state.ask_serial.lock().unwrap_or_else(|e| e.into_inner()); state.waiting.fetch_sub(1, Ordering::SeqCst); let options_json = if b.options.is_empty() { @@ -202,7 +193,7 @@ fn handle_ask( }; let timeout_s = b.timeout_s.clamp(5, MAX_TIMEOUT_S); let pending = state.begin_question(id); - sink(QuestionEvent { + let ev = QuestionEvent { id, kind: b.kind.clone(), question: b.question.clone(), @@ -210,8 +201,13 @@ fn handle_ask( timeout_s, speak: b.speak, source: b.source.clone(), - }); + }; + // Recorded BEFORE the sink so a freshly created panel window can pull it + // on mount even if the emitted event raced past an unloaded webview. + state.set_current(ev.clone()); + sink(ev); let outcome = pending.wait(Duration::from_secs(timeout_s)); + state.clear_current_if(id); let (status, answer) = match &outcome { Outcome::Answered(a) => { let _ = store.mark_answered(id, a); diff --git a/src-tauri/src/agent_bridge/state.rs b/src-tauri/src/agent_bridge/state.rs index 0d852c3..6dc81f1 100644 --- a/src-tauri/src/agent_bridge/state.rs +++ b/src-tauri/src/agent_bridge/state.rs @@ -10,9 +10,26 @@ pub enum Outcome { Timeout, } +/// What the UI layer needs to know to show a question. Also returned by the +/// `agent_bridge_current` command so a freshly created panel window can pull +/// the active question on mount (the `agent-question` event may fire before +/// the webview is ready to listen — cold-window race). +#[derive(Clone, Debug, serde::Serialize, specta::Type)] +pub struct QuestionEvent { + pub id: i64, + pub kind: String, + pub question: String, + pub options: Vec, + pub timeout_s: u64, + pub speak: bool, + pub source: String, +} + #[derive(Clone)] pub struct BridgeState { pending: Arc>>>, + /// The question currently on screen (None between questions). + current: Arc>>, /// Serializes asks so the panel shows one question at a time. pub ask_serial: Arc>, /// Number of asks waiting for the serial lock (for the 429 cap). @@ -29,11 +46,22 @@ impl BridgeState { pub fn new() -> Self { Self { pending: Arc::new(Mutex::new(HashMap::new())), + current: Arc::new(Mutex::new(None)), ask_serial: Arc::new(Mutex::new(())), waiting: Arc::new(std::sync::atomic::AtomicUsize::new(0)), } } + /// Records the question currently shown by the panel. + pub fn set_current(&self, ev: QuestionEvent) { + *lock_ok(&self.current) = Some(ev); + } + + /// The question currently on screen, if any (panel pulls this on mount). + pub fn current(&self) -> Option { + lock_ok(&self.current).clone() + } + pub fn begin_question(&self, id: i64) -> PendingQuestion { let (tx, rx) = channel(); let prev = lock_ok(&self.pending).insert(id, tx); @@ -48,6 +76,7 @@ impl BridgeState { /// Returns false if the id is unknown (already resolved / timed out). pub fn resolve(&self, id: i64, outcome: Outcome) -> bool { if let Some(tx) = lock_ok(&self.pending).remove(&id) { + self.clear_current_if(id); let _ = tx.send(outcome); true } else { @@ -55,9 +84,12 @@ impl BridgeState { } } - /// Id of the oldest pending question, if any (used by the panel on mount). - pub fn pending_id(&self) -> Option { - lock_ok(&self.pending).keys().min().copied() + /// Clears the on-screen question if it is `id` (resolve and timeout paths). + pub fn clear_current_if(&self, id: i64) { + let mut cur = lock_ok(&self.current); + if cur.as_ref().is_some_and(|c| c.id == id) { + *cur = None; + } } } diff --git a/src-tauri/src/agent_bridge/storage.rs b/src-tauri/src/agent_bridge/storage.rs index 4568da1..8b2daab 100644 --- a/src-tauri/src/agent_bridge/storage.rs +++ b/src-tauri/src/agent_bridge/storage.rs @@ -41,6 +41,7 @@ impl BridgeStore { }) } + #[cfg(test)] pub fn open_in_memory() -> Result { let conn = Connection::open_in_memory()?; conn.execute_batch(SCHEMA)?; diff --git a/src-tauri/src/agent_bridge/window.rs b/src-tauri/src/agent_bridge/window.rs index fcb1289..3f80e54 100644 --- a/src-tauri/src/agent_bridge/window.rs +++ b/src-tauri/src/agent_bridge/window.rs @@ -1,4 +1,7 @@ use tauri::AppHandle; -/// Stub until plan Task 7 creates the real panel window. +// Stubs until plan Task 7 creates the real panel window. + pub fn show_panel(_app: &AppHandle) {} + +pub fn hide_panel(_app: &AppHandle) {} diff --git a/src-tauri/src/commands/agent_bridge.rs b/src-tauri/src/commands/agent_bridge.rs new file mode 100644 index 0000000..af836f7 --- /dev/null +++ b/src-tauri/src/commands/agent_bridge.rs @@ -0,0 +1,54 @@ +use crate::agent_bridge::state::{BridgeState, Outcome, QuestionEvent}; +use crate::agent_bridge::storage::{BridgeStore, QuestionRow}; +use std::sync::Arc; +use tauri::State; + +#[tauri::command] +#[specta::specta] +pub fn agent_bridge_answer( + app: tauri::AppHandle, + state: State, + id: i64, + answer: String, +) -> Result<(), String> { + // Hide regardless of the resolve outcome: the question may have just + // timed out server-side, but the panel must not linger. + crate::agent_bridge::window::hide_panel(&app); + if state.resolve(id, Outcome::Answered(answer)) { + Ok(()) + } else { + Err("question already resolved".into()) + } +} + +#[tauri::command] +#[specta::specta] +pub fn agent_bridge_dismiss( + app: tauri::AppHandle, + state: State, + id: i64, +) -> Result<(), String> { + crate::agent_bridge::window::hide_panel(&app); + if state.resolve(id, Outcome::Dismissed) { + Ok(()) + } else { + Err("question already resolved".into()) + } +} + +/// The question currently on screen, if any — pulled by the panel on mount +/// (the `agent-question` event can race past a cold webview). +#[tauri::command] +#[specta::specta] +pub fn agent_bridge_current(state: State) -> Option { + state.current() +} + +#[tauri::command] +#[specta::specta] +pub fn agent_bridge_answers( + store: State>, + since_ms: i64, +) -> Result, String> { + store.list_since(since_ms).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index db67a65..fa6ed90 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod agent_bridge; pub mod audio; pub mod coach; pub mod history; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 107ff3e..9feb993 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -167,6 +167,61 @@ fn initialize_core_logic(app_handle: &AppHandle) { Arc::new(HistoryManager::new(app_handle).expect("Failed to initialize history manager")); let tts_manager = Arc::new(crate::tts::TtsManager::new()); + // Agent Bridge: localhost HTTP API for agent↔user questions + let bridge_settings = crate::settings::get_settings(app_handle); + if bridge_settings.agent_bridge_enabled { + match app_handle.path().app_data_dir() { + Ok(app_data) => { + match ( + crate::agent_bridge::token::load_or_create_token(&app_data), + crate::agent_bridge::storage::BridgeStore::open( + &app_data.join("agent_bridge.db"), + ), + ) { + (Ok(token), Ok(store)) => { + let store = Arc::new(store); + let bridge_state = crate::agent_bridge::state::BridgeState::new(); + app_handle.manage(bridge_state.clone()); + app_handle.manage(store.clone()); + let evt_handle = app_handle.clone(); + let sink: crate::agent_bridge::server::AskSink = Arc::new(move |ev| { + use tauri::Emitter; + if ev.speak { + if let Some(tts) = + evt_handle.try_state::>() + { + let _ = tts.speak(ev.question.clone(), None); + } + } + crate::agent_bridge::window::show_panel(&evt_handle); + let _ = evt_handle.emit("agent-question", &ev); + }); + match crate::agent_bridge::server::start_server( + crate::agent_bridge::server::ServerConfig { + port: bridge_settings.agent_bridge_port, + token, + }, + store, + bridge_state, + sink, + ) { + Ok(port) => { + log::info!("agent-bridge listening on 127.0.0.1:{}", port) + } + Err(e) => log::error!("agent-bridge failed to start: {}", e), + } + } + (t, s) => log::error!( + "agent-bridge init failed: token_err={:?} store_ok={}", + t.err(), + s.is_ok() + ), + } + } + Err(e) => log::error!("agent-bridge: no app data dir: {}", e), + } + } + // Apply accelerator preferences before any model loads managers::transcription::apply_accelerator_settings(app_handle); @@ -467,6 +522,10 @@ pub fn run(cli_args: CliArgs) { commands::tts::tts_list_voices, commands::tts::tts_speak, commands::tts::tts_stop, + commands::agent_bridge::agent_bridge_answer, + commands::agent_bridge::agent_bridge_dismiss, + commands::agent_bridge::agent_bridge_answers, + commands::agent_bridge::agent_bridge_current, commands::coach::get_coach_dashboard, commands::coach::get_coach_baseline, helpers::clamshell::is_laptop, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1d80591..6a43d3d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -388,6 +388,10 @@ pub struct AppSettings { pub debug_mode: bool, #[serde(default = "default_log_level")] pub log_level: LogLevel, + #[serde(default = "default_agent_bridge_enabled")] + pub agent_bridge_enabled: bool, + #[serde(default = "default_agent_bridge_port")] + pub agent_bridge_port: u16, #[serde(default)] pub custom_words: Vec, #[serde(default)] @@ -579,6 +583,14 @@ fn default_log_level() -> LogLevel { LogLevel::Debug } +fn default_agent_bridge_enabled() -> bool { + true +} + +fn default_agent_bridge_port() -> u16 { + 4123 +} + fn default_word_correction_threshold() -> f64 { 0.18 } @@ -919,6 +931,8 @@ pub fn get_default_settings() -> AppSettings { translate_to_english: false, selected_language: "auto".to_string(), overlay_position: default_overlay_position(), + agent_bridge_enabled: default_agent_bridge_enabled(), + agent_bridge_port: default_agent_bridge_port(), debug_mode: false, log_level: default_log_level(), custom_words: Vec::new(), diff --git a/src/bindings.ts b/src/bindings.ts index 9a48ae6..9badac5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -963,6 +963,61 @@ async cancelFileTranscription() : Promise> { else return { status: "error", error: e as any }; } }, +async ttsListVoices() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("tts_list_voices") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async ttsSpeak(text: string, voiceId: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("tts_speak", { text, voiceId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async ttsStop() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("tts_stop") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async agentBridgeAnswer(id: number, answer: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("agent_bridge_answer", { id, answer }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async agentBridgeDismiss(id: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("agent_bridge_dismiss", { id }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async agentBridgeAnswers(sinceMs: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("agent_bridge_answers", { sinceMs }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * The question currently on screen, if any — pulled by the panel on mount + * (the `agent-question` event can race past a cold webview). + */ +async agentBridgeCurrent() : Promise { + return await TAURI_INVOKE("agent_bridge_current"); +}, async getCoachDashboard(window: TrendWindow) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_coach_dashboard", { window }) }; @@ -1008,7 +1063,7 @@ historyUpdatePayload: "history-update-payload" /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; translate_enabled?: boolean; translate_target?: Lang; translate_model?: string; translate_base_url?: string; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; capture_folder?: string; capture_trigger_phrases?: string; custom_filler_words?: string[] | null; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number; auto_punctuate?: boolean; auto_capitalize?: boolean; subtitle_overlay?: boolean; subtitle_font_size?: SubtitleFontSize; subtitle_max_chars?: number; subtitle_refresh_ms?: number; command_mode_enabled?: boolean; coach_toast_enabled?: boolean; snippets?: Snippet[]; self_correction_enabled?: boolean; spoken_lists_enabled?: boolean; dev_dictionary_enabled?: boolean } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; agent_bridge_enabled?: boolean; agent_bridge_port?: number; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; translate_enabled?: boolean; translate_target?: Lang; translate_model?: string; translate_base_url?: string; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; capture_folder?: string; capture_trigger_phrases?: string; custom_filler_words?: string[] | null; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number; auto_punctuate?: boolean; auto_capitalize?: boolean; subtitle_overlay?: boolean; subtitle_font_size?: SubtitleFontSize; subtitle_max_chars?: number; subtitle_refresh_ms?: number; command_mode_enabled?: boolean; coach_toast_enabled?: boolean; snippets?: Snippet[]; self_correction_enabled?: boolean; spoken_lists_enabled?: boolean; dev_dictionary_enabled?: boolean } export type AudioDevice = { index: string; name: string; is_default: boolean } export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter" export type AvailableAccelerators = { whisper: string[]; ort: string[]; gpu_devices: GpuDeviceOption[] } @@ -1043,6 +1098,14 @@ export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_ export type PeriodSummary = { avg_wpm: number; avg_filler_rate: number; session_count: number; prev_avg_wpm: number; prev_avg_filler_rate: number; prev_session_count: number } export type PermissionAccess = "allowed" | "denied" | "unknown" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } +/** + * What the UI layer needs to know to show a question. Also returned by the + * `agent_bridge_current` command so a freshly created panel window can pull + * the active question on mount (the `agent-question` event may fire before + * the webview is ready to listen — cold-window race). + */ +export type QuestionEvent = { id: number; kind: string; question: string; options: string[]; timeout_s: number; speak: boolean; source: string } +export type QuestionRow = { id: number; source: string; kind: string; question: string; options: string | null; answer: string | null; status: string; asked_at: number; answered_at: number | null } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type SecretMap = Partial<{ [key in string]: string }> export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } @@ -1059,6 +1122,7 @@ export type SubtitleFontSize = "small" | "medium" | "large" export type TrendPoint = { day: number; avg_wpm: number; avg_filler_rate: number } export type TrendWindow = "Days7" | "Days30" | "All" export type TypingTool = "auto" | "wtype" | "kwtype" | "dotool" | "ydotool" | "xdotool" +export type VoiceInfo = { id: string; display_name: string; language: string } export type WhisperAcceleratorSetting = "auto" | "cpu" | "gpu" export type WindowsMicrophonePermissionStatus = { supported: boolean; overall_access: PermissionAccess; device_access: PermissionAccess; app_access: PermissionAccess; desktop_app_access: PermissionAccess } From 3fae0710e5da4b86e5eba841e397911a3fa78dee Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 10:10:28 -0500 Subject: [PATCH 14/19] feat(agent-bridge): floating question panel (multi-entry vite page) agent_panel webview (always-on-top, undecorated, focusable) + AgentPanel.tsx: text/choice/confirm kinds, countdown with self-dismiss, cold-window pull via agentBridgeCurrent, dictation-friendly text input. i18n keys in en+ru, parity across all 21 locales. Implemented by Gemini CLI under spec; always-hide ordering in answer/dismiss commands restored in review. Co-Authored-By: Claude Fable 5 --- src-tauri/src/agent_bridge/window.rs | 37 ++++++- src/agent-panel/AgentPanel.tsx | 134 ++++++++++++++++++++++++ src/agent-panel/index.html | 27 +++++ src/agent-panel/main.tsx | 10 ++ src/i18n/locales/ar/translation.json | 8 ++ src/i18n/locales/bg/translation.json | 8 ++ src/i18n/locales/cs/translation.json | 8 ++ src/i18n/locales/de/translation.json | 8 ++ src/i18n/locales/en/translation.json | 8 ++ src/i18n/locales/es/translation.json | 8 ++ src/i18n/locales/fr/translation.json | 8 ++ src/i18n/locales/he/translation.json | 8 ++ src/i18n/locales/it/translation.json | 8 ++ src/i18n/locales/ja/translation.json | 8 ++ src/i18n/locales/ko/translation.json | 8 ++ src/i18n/locales/pl/translation.json | 8 ++ src/i18n/locales/pt/translation.json | 8 ++ src/i18n/locales/ru/translation.json | 8 ++ src/i18n/locales/sv/translation.json | 8 ++ src/i18n/locales/tr/translation.json | 8 ++ src/i18n/locales/uk/translation.json | 8 ++ src/i18n/locales/vi/translation.json | 8 ++ src/i18n/locales/zh-TW/translation.json | 8 ++ src/i18n/locales/zh/translation.json | 8 ++ vite.config.ts | 1 + 25 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 src/agent-panel/AgentPanel.tsx create mode 100644 src/agent-panel/index.html create mode 100644 src/agent-panel/main.tsx diff --git a/src-tauri/src/agent_bridge/window.rs b/src-tauri/src/agent_bridge/window.rs index 3f80e54..9338260 100644 --- a/src-tauri/src/agent_bridge/window.rs +++ b/src-tauri/src/agent_bridge/window.rs @@ -1,7 +1,36 @@ -use tauri::AppHandle; +use tauri::{AppHandle, Manager, WebviewWindowBuilder}; -// Stubs until plan Task 7 creates the real panel window. +pub const PANEL_LABEL: &str = "agent_panel"; +const W: f64 = 400.0; +const H: f64 = 240.0; -pub fn show_panel(_app: &AppHandle) {} +/// Creates (once) and shows the agent question panel: always-on-top, +/// undecorated, skips taskbar, but CAN take focus (text input). +pub fn show_panel(app: &AppHandle) { + if let Some(win) = app.get_webview_window(PANEL_LABEL) { + let _ = win.show(); + let _ = win.set_focus(); + return; + } + let builder = WebviewWindowBuilder::new( + app, + PANEL_LABEL, + tauri::WebviewUrl::App("src/agent-panel/index.html".into()), + ) + .title("Echo — agent question") + .inner_size(W, H) + .resizable(false) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(true); + if let Err(e) = builder.build() { + log::error!("agent panel window: {e}"); + } +} -pub fn hide_panel(_app: &AppHandle) {} +pub fn hide_panel(app: &AppHandle) { + if let Some(win) = app.get_webview_window(PANEL_LABEL) { + let _ = win.hide(); + } +} diff --git a/src/agent-panel/AgentPanel.tsx b/src/agent-panel/AgentPanel.tsx new file mode 100644 index 0000000..1f15958 --- /dev/null +++ b/src/agent-panel/AgentPanel.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { useTranslation } from "react-i18next"; +import { commands } from "@/bindings"; + +type QuestionEvent = { + id: number; + kind: "text" | "choice" | "confirm" | "notify"; + question: string; + options: string[]; + timeout_s: number; + speak: boolean; + source: string; +}; + +export default function AgentPanel() { + const { t } = useTranslation(); + const [q, setQ] = useState(null); + const [text, setText] = useState(""); + const [timeLeft, setTimeLeft] = useState(null); + + useEffect(() => { + // Cold-window race fix: pull active question on mount + commands.agentBridgeCurrent().then((cur) => { + if (cur) { + setQ(cur as QuestionEvent); + if (cur.timeout_s > 0) setTimeLeft(cur.timeout_s); + } + }); + + const un = listen("agent-question", (e) => { + setQ(e.payload); + setText(""); + if (e.payload.timeout_s > 0) setTimeLeft(e.payload.timeout_s); + else setTimeLeft(null); + }); + return () => { + un.then((f) => f()); + }; + }, []); + + useEffect(() => { + if (timeLeft === null || timeLeft <= 0 || !q) return; + + const timer = setInterval(() => { + setTimeLeft((prev) => { + if (prev === null || prev <= 1) { + clearInterval(timer); + // Timeout reached: dismiss + commands.agentBridgeDismiss(q.id).catch(() => {}); + setQ(null); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [timeLeft, q]); + + if (!q) return null; + + const submit = (answer: string) => { + if (q.kind !== "notify") commands.agentBridgeAnswer(q.id, answer); + setQ(null); + }; + const dismiss = () => { + if (q.kind !== "notify") commands.agentBridgeDismiss(q.id); + setQ(null); + }; + + return ( +
+
+
+ {t("agentPanel.from", { source: q.source })} +
+ {timeLeft !== null && ( +
+ {t("agentPanel.timeLeft", { count: timeLeft })} +
+ )} +
+
{q.question}
+ {q.kind === "text" && ( + setText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && text.trim() && submit(text.trim())} + /> + )} + {q.kind === "choice" && ( +
+ {q.options.map((o) => ( + + ))} +
+ )} + {q.kind === "confirm" && ( +
+ + +
+ )} +
+ +
+
+ ); +} diff --git a/src/agent-panel/index.html b/src/agent-panel/index.html new file mode 100644 index 0000000..42176c7 --- /dev/null +++ b/src/agent-panel/index.html @@ -0,0 +1,27 @@ + + + + + Agent Panel + + + +
+ + + diff --git a/src/agent-panel/main.tsx b/src/agent-panel/main.tsx new file mode 100644 index 0000000..aea653f --- /dev/null +++ b/src/agent-panel/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import AgentPanel from "./AgentPanel"; +import "@/i18n"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 2147818..07ca84d 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -739,5 +739,13 @@ "processing": "...جاري المعالجة", "cancel": "إلغاء التسجيل", "preparing": "جارٍ التحضير…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 9c7c82a..7814c76 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -739,5 +739,13 @@ "processing": "Обработка...", "cancel": "Отказ на записа", "preparing": "Подготовка…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 9ef2d85..010934c 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -739,5 +739,13 @@ "processing": "Zpracovávám...", "cancel": "Zrušit nahrávání", "preparing": "Příprava…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 2e7f642..3e086aa 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -739,5 +739,13 @@ "processing": "Verarbeite...", "cancel": "Aufnahme abbrechen", "preparing": "Wird vorbereitet…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index a528f59..aa71e84 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -739,5 +739,13 @@ "processing": "Processing...", "cancel": "Cancel recording", "preparing": "Preparing…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index b431ba5..0aa0d32 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -739,5 +739,13 @@ "processing": "Procesando...", "cancel": "Cancelar grabación", "preparing": "Preparando…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 0c0f5e3..f2ed05d 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -739,5 +739,13 @@ "processing": "Traitement...", "cancel": "Annuler l'enregistrement", "preparing": "Préparation…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 6cd6ed7..7a783c9 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -739,5 +739,13 @@ "processing": "מעבד...", "cancel": "ביטול ההקלטה", "preparing": "מתכונן…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 2b1fe91..8b69cae 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -739,5 +739,13 @@ "processing": "Elaborazione...", "cancel": "Annulla registrazione", "preparing": "Preparazione…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 32a0c0b..ccc489a 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -739,5 +739,13 @@ "processing": "処理中...", "cancel": "録音をキャンセル", "preparing": "準備中…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 86fbf7b..d3eb2da 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -739,5 +739,13 @@ "processing": "처리 중...", "cancel": "녹음 취소", "preparing": "준비 중…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index d50f118..1701272 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -739,5 +739,13 @@ "processing": "Przetwarzanie...", "cancel": "Anuluj nagrywanie", "preparing": "Przygotowywanie…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 3e257a5..45c4d8a 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -739,5 +739,13 @@ "processing": "Processando...", "cancel": "Cancelar gravação", "preparing": "Preparando…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 93d25ba..a0532ed 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -739,5 +739,13 @@ "processing": "Обработка...", "cancel": "Отменить запись", "preparing": "Подготовка…" + }, + "agentPanel": { + "from": "Вопрос от {{source}}", + "typeOrDictate": "Введите ответ — или ответьте голосом с помощью горячей клавиши", + "yes": "Да", + "no": "Нет", + "dismiss": "Закрыть", + "timeLeft": "{{count}}с" } } diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index 150f2cf..4574c9e 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -739,5 +739,13 @@ "processing": "Bearbetar...", "cancel": "Avbryt inspelning", "preparing": "Förbereder…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 27e9b4d..93e1d32 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -739,5 +739,13 @@ "processing": "İşleniyor...", "cancel": "Kaydı iptal et", "preparing": "Hazırlanıyor…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index d735ae4..9cc9e82 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -739,5 +739,13 @@ "processing": "Постобробка...", "cancel": "Скасувати запис", "preparing": "Підготовка…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 77297f2..93423ce 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -739,5 +739,13 @@ "processing": "Đang xử lý...", "cancel": "Hủy ghi âm", "preparing": "Đang chuẩn bị…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 4a97d4b..4a9857e 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -739,5 +739,13 @@ "processing": "處理中...", "cancel": "取消錄音", "preparing": "準備中…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 90aa8b8..e0fe16e 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -739,5 +739,13 @@ "processing": "处理中...", "cancel": "取消录音", "preparing": "准备中…" + }, + "agentPanel": { + "from": "Question from {{source}}", + "typeOrDictate": "Type — or answer by voice with your dictation hotkey", + "yes": "Yes", + "no": "No", + "dismiss": "Dismiss", + "timeLeft": "{{count}}s" } } diff --git a/vite.config.ts b/vite.config.ts index 086602f..6190e17 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig(async () => ({ input: { main: resolve(__dirname, "index.html"), overlay: resolve(__dirname, "src/overlay/index.html"), + agentPanel: resolve(__dirname, "src/agent-panel/index.html"), }, }, }, From 8d592b7c57736b0757b6a988c9bf556c7a0b0151 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 10:16:58 -0500 Subject: [PATCH 15/19] feat(agent-bridge): CLI client echo --ask --- src-tauri/src/cli.rs | 16 +++++++++ src-tauri/src/cli_ask.rs | 76 ++++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 22 ++++++++++-- 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/cli_ask.rs diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index f62df87..18ded6e 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -63,4 +63,20 @@ pub struct CliArgs { /// translated plain text. Value is a language code like "en"/"ru"/"uk". #[arg(long, value_name = "LANG")] pub translate: Option, + + /// Ask the user a question via the running Echo instance and print the answer + #[arg(long, value_name = "TEXT")] + pub ask: Option, + /// Comma-separated options (makes --ask a choice question) + #[arg(long, value_name = "A,B,C")] + pub ask_options: Option, + /// Timeout in seconds for --ask (default 300) + #[arg(long, default_value_t = 300)] + pub ask_timeout: u64, + /// Speak the question aloud (TTS) + #[arg(long, default_value_t = false)] + pub ask_speak: bool, + /// Agent Bridge port (default 4123) + #[arg(long, default_value_t = 4123)] + pub ask_port: u16, } diff --git a/src-tauri/src/cli_ask.rs b/src-tauri/src/cli_ask.rs new file mode 100644 index 0000000..be0647d --- /dev/null +++ b/src-tauri/src/cli_ask.rs @@ -0,0 +1,76 @@ +use anyhow::{Context, Result}; + +pub fn run_ask( + question: &str, + options: Option<&str>, + timeout_s: u64, + speak: bool, + port: u16, +) -> Result { + let token_path = crate::portable::data_dir() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| dirs_path().expect("app data dir")) + .join(crate::agent_bridge::token::TOKEN_FILE); + let token = std::fs::read_to_string(&token_path) + .with_context(|| { + format!( + "token not found at {} — is Echo running?", + token_path.display() + ) + })? + .trim() + .to_string(); + let opts: Vec = options + .map(|s| s.split(',').map(|x| x.trim().to_string()).collect()) + .unwrap_or_default(); + let body = serde_json::json!({ + "question": question, + "kind": if opts.is_empty() { "text" } else { "choice" }, + "options": opts, + "timeout_s": timeout_s, + "speak": speak, + "source": "cli", + }); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let resp: serde_json::Value = rt.block_on(async { + reqwest::Client::new() + .post(format!("http://127.0.0.1:{port}/v1/ask")) + .bearer_auth(&token) + .json(&body) + .timeout(std::time::Duration::from_secs(timeout_s + 30)) + .send() + .await? + .json() + .await + .map_err(anyhow::Error::from) + })?; + match resp["status"].as_str() { + Some("answered") => { + println!("{}", resp["answer"].as_str().unwrap_or_default()); + Ok(0) + } + Some("timeout") => Ok(2), + _ => Ok(3), + } +} + +/// %APPDATA%\com.sovern.echo equivalent without an AppHandle. +fn dirs_path() -> Option { + #[cfg(windows)] + { + std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("com.sovern.echo")) + } + #[cfg(target_os = "macos")] + { + std::env::var_os("HOME").map(|h| { + std::path::PathBuf::from(h).join("Library/Application Support/com.sovern.echo") + }) + } + #[cfg(all(not(windows), not(target_os = "macos")))] + { + std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".local/share/com.sovern.echo")) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9feb993..ed64484 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod apple_intelligence; pub mod audio_toolkit; mod capture; pub mod cli; +mod cli_ask; mod cli_transcription; mod coach; mod coach_progress; @@ -394,6 +395,21 @@ pub fn run(cli_args: CliArgs) { // Detect portable mode before anything else portable::init(); + if let Some(question) = cli_args.ask.as_deref() { + let code = crate::cli_ask::run_ask( + question, + cli_args.ask_options.as_deref(), + cli_args.ask_timeout, + cli_args.ask_speak, + cli_args.ask_port, + ) + .unwrap_or_else(|e| { + eprintln!("error: {e}"); + 1 + }); + std::process::exit(code); + } + // Parse console logging directives from RUST_LOG, falling back to info-level logging // when the variable is unset let console_filter = build_console_filter(); @@ -583,9 +599,9 @@ pub fn run(cli_args: CliArgs) { } // Single-instance forwards CLI flags to a running GUI instance. Skip it in - // headless --transcribe-file mode so the CLI always runs in its own process, - // even when the GUI is already open. - if cli_args.transcribe_file.is_none() { + // headless --transcribe-file or --ask mode so the CLI always runs in its + // own process, even when the GUI is already open. + if cli_args.transcribe_file.is_none() && cli_args.ask.is_none() { builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if args.iter().any(|a| a == "--toggle-transcription") { platform::signal_handle::send_transcription_input(app, "transcribe", "CLI"); From de6f3cda39134aa727519eb00b36b173fdd6ff31 Mon Sep 17 00:00:00 2001 From: master5d Date: Fri, 12 Jun 2026 10:17:01 -0500 Subject: [PATCH 16/19] docs(agent-bridge): skill + README --- README.md | 18 ++++++++++++++++++ skills/echo-ask/SKILL.md | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 skills/echo-ask/SKILL.md diff --git a/README.md b/README.md index c839c7c..9042a26 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,24 @@ echo --help # list all flags Debug mode toggles with `Ctrl+Shift+D` (Windows/Linux) or `Cmd+Shift+D` (macOS). +## Agent Bridge + +Echo exposes a localhost HTTP API (`127.0.0.1:4123`) that allows external agents and scripts to "speak" to the user via Echo's floating panel. It's protected by a bearer token generated on first run. + +```bash +# Example: Ask the user a question from a script +TOKEN=$(cat "$APPDATA/com.sovern.echo/agent_bridge_token") +curl -X POST http://127.0.0.1:4123/v1/ask \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "question": "Deploy to production?", + "kind": "choice", + "options": ["Yes", "No"], + "source": "deploy-script" + }' +``` + ## Platform support macOS (Intel + Apple Silicon), x64 Windows, x64 Linux (Ubuntu 22.04 / 24.04). diff --git a/skills/echo-ask/SKILL.md b/skills/echo-ask/SKILL.md new file mode 100644 index 0000000..89c891b --- /dev/null +++ b/skills/echo-ask/SKILL.md @@ -0,0 +1,21 @@ +--- +name: echo-ask +description: Ask the human a question through Echo's floating panel (Windows). Use when a task needs user input, confirmation, or a periodic check-in, and you are running on the user's machine alongside Echo. +--- + +# Echo Ask + +Echo (the dictation app) exposes a localhost Agent Bridge. + +1. Read the token: `%APPDATA%\com.sovern.echo\agent_bridge_token`. +2. POST `http://127.0.0.1:4123/v1/ask` with `Authorization: Bearer `: + `{ "question": "...", "kind": "text"|"choice"|"confirm", "options": [...], + "timeout_s": 300, "speak": true|false, "source": "" }` + The call BLOCKS until the human answers / dismisses / timeout: + `{ "status": "answered"|"dismissed"|"timeout", "answer": "..." }`. +3. Fire-and-forget messages: POST `/v1/notify` `{ "message": "...", "speak": true }`. +4. Journal: GET `/v1/answers?since=`. + +Etiquette: one question at a time (server enforces it); short questions; +set `speak:true` only for time-sensitive asks; always set `source`. +CLI alternative (cron loops): `echo --ask "..." --ask-options "a,b" --ask-timeout 60`. From 49870fdd3dac71d6b5d4fca95eecac323d2dfd48 Mon Sep 17 00:00:00 2001 From: master5d Date: Sat, 13 Jun 2026 15:38:07 -0500 Subject: [PATCH 17/19] fix(agent-bridge): portable-mode token path + CLI error surfacing (agy review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server now resolves app data via portable::app_data_dir so the token lives where the CLI reads it (portable mode previously split %APPDATA% vs Data/, 401ing every --ask) - CLI surfaces non-2xx server responses (401/429/400) with the error body instead of exiting 3 silently - CLI request timeout uses saturating_add (no overflow on huge --ask-timeout) agy also flagged two "compile errors" (tiny_http to_ip/as_str) — false positives, 155 tests + release build prove the API is correct; and a localhost timing-attack on the bearer token — out of threat model (token file is per-user ACL-protected; a local attacker reads the file directly). Co-Authored-By: Claude Fable 5 --- src-tauri/src/cli_ask.rs | 21 +++++++++++++++------ src-tauri/src/lib.rs | 5 ++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/cli_ask.rs b/src-tauri/src/cli_ask.rs index be0647d..961ae72 100644 --- a/src-tauri/src/cli_ask.rs +++ b/src-tauri/src/cli_ask.rs @@ -35,16 +35,25 @@ pub fn run_ask( .enable_all() .build()?; let resp: serde_json::Value = rt.block_on(async { - reqwest::Client::new() + let r = reqwest::Client::new() .post(format!("http://127.0.0.1:{port}/v1/ask")) .bearer_auth(&token) .json(&body) - .timeout(std::time::Duration::from_secs(timeout_s + 30)) + // saturating: a huge --ask-timeout must not overflow the Duration. + .timeout(std::time::Duration::from_secs(timeout_s.saturating_add(30))) .send() - .await? - .json() - .await - .map_err(anyhow::Error::from) + .await?; + let status = r.status(); + let body: serde_json::Value = r.json().await?; + // Surface server-side rejections (401/429/400) instead of exiting silently. + if !status.is_success() { + let msg = body + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("request rejected"); + anyhow::bail!("server returned {}: {}", status.as_u16(), msg); + } + Ok::<_, anyhow::Error>(body) })?; match resp["status"].as_str() { Some("answered") => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed64484..100fd18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -171,7 +171,10 @@ fn initialize_core_logic(app_handle: &AppHandle) { // Agent Bridge: localhost HTTP API for agent↔user questions let bridge_settings = crate::settings::get_settings(app_handle); if bridge_settings.agent_bridge_enabled { - match app_handle.path().app_data_dir() { + // Portable-aware: must match where the CLI (cli_ask.rs) reads the token, + // else portable mode splits the server's %APPDATA% token from the CLI's + // Data/ token and every --ask 401s. + match crate::portable::app_data_dir(app_handle) { Ok(app_data) => { match ( crate::agent_bridge::token::load_or_create_token(&app_data), From 6985265c8820467810f7fba148ea913f48526e65 Mon Sep 17 00:00:00 2001 From: master5d Date: Sun, 14 Jun 2026 01:29:40 -0500 Subject: [PATCH 18/19] style: prettier --write on agent-bridge docs + panel (CI format gate) BUILD.md, echo-ask SKILL.md, AgentPanel.tsx were not prettier-clean (eslint passes them but format:check is a separate gate). Other locally-flagged files are CRLF working-tree false positives (LF blobs are clean on CI). Co-Authored-By: Claude Fable 5 --- BUILD.md | 6 ++++-- skills/echo-ask/SKILL.md | 2 +- src/agent-panel/AgentPanel.tsx | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/BUILD.md b/BUILD.md index e69345f..622059e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -137,9 +137,11 @@ which handles the LLVM environment and Vulkan SDK detection: ### Manual workflow 1. **Cargo Profile**: Use `--profile fast` for daily builds. + ```bash cargo build --manifest-path src-tauri/Cargo.toml --profile fast ``` + Note: `npm run tauri build` always uses the `release` profile — tauri-cli appends `--release` itself and the bundler expects `target/release` artifacts, so custom profiles apply to bare `cargo build` only. @@ -154,8 +156,8 @@ which handles the LLVM environment and Vulkan SDK detection: ```bash npm run tauri dev -- -- --no-default-features ``` - *Note: If built without default features, passing `--diarize` at runtime - will error by design.* + _Note: If built without default features, passing `--diarize` at runtime + will error by design._ ## Linux Install (from source) diff --git a/skills/echo-ask/SKILL.md b/skills/echo-ask/SKILL.md index 89c891b..2a36aff 100644 --- a/skills/echo-ask/SKILL.md +++ b/skills/echo-ask/SKILL.md @@ -10,7 +10,7 @@ Echo (the dictation app) exposes a localhost Agent Bridge. 1. Read the token: `%APPDATA%\com.sovern.echo\agent_bridge_token`. 2. POST `http://127.0.0.1:4123/v1/ask` with `Authorization: Bearer `: `{ "question": "...", "kind": "text"|"choice"|"confirm", "options": [...], - "timeout_s": 300, "speak": true|false, "source": "" }` + "timeout_s": 300, "speak": true|false, "source": "" }` The call BLOCKS until the human answers / dismisses / timeout: `{ "status": "answered"|"dismissed"|"timeout", "answer": "..." }`. 3. Fire-and-forget messages: POST `/v1/notify` `{ "message": "...", "speak": true }`. diff --git a/src/agent-panel/AgentPanel.tsx b/src/agent-panel/AgentPanel.tsx index 1f15958..9685aa9 100644 --- a/src/agent-panel/AgentPanel.tsx +++ b/src/agent-panel/AgentPanel.tsx @@ -89,7 +89,9 @@ export default function AgentPanel() { value={text} placeholder={t("agentPanel.typeOrDictate")} onChange={(e) => setText(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && text.trim() && submit(text.trim())} + onKeyDown={(e) => + e.key === "Enter" && text.trim() && submit(text.trim()) + } /> )} {q.kind === "choice" && ( From 5d25447520ef1921ea1c12428dedaadfc83ff8aa Mon Sep 17 00:00:00 2001 From: master5d Date: Sun, 14 Jun 2026 01:32:56 -0500 Subject: [PATCH 19/19] style: rewrite echo-ask SKILL.md with fenced blocks (prettier-idempotent) Multi-line inline-code inside a numbered list item is a prettier edge case that --write did not stabilize; moved the JSON/CLI examples into proper fenced blocks so format:check passes. Co-Authored-By: Claude Fable 5 --- skills/echo-ask/SKILL.md | 43 ++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/skills/echo-ask/SKILL.md b/skills/echo-ask/SKILL.md index 2a36aff..8eb7e04 100644 --- a/skills/echo-ask/SKILL.md +++ b/skills/echo-ask/SKILL.md @@ -7,15 +7,38 @@ description: Ask the human a question through Echo's floating panel (Windows). U Echo (the dictation app) exposes a localhost Agent Bridge. -1. Read the token: `%APPDATA%\com.sovern.echo\agent_bridge_token`. -2. POST `http://127.0.0.1:4123/v1/ask` with `Authorization: Bearer `: - `{ "question": "...", "kind": "text"|"choice"|"confirm", "options": [...], - "timeout_s": 300, "speak": true|false, "source": "" }` - The call BLOCKS until the human answers / dismisses / timeout: - `{ "status": "answered"|"dismissed"|"timeout", "answer": "..." }`. -3. Fire-and-forget messages: POST `/v1/notify` `{ "message": "...", "speak": true }`. +1. Read the token from `%APPDATA%\com.sovern.echo\agent_bridge_token`. +2. POST to `http://127.0.0.1:4123/v1/ask` with header `Authorization: Bearer `. The call BLOCKS until the human answers, dismisses, or it times out. + +Request body: + +```json +{ + "question": "...", + "kind": "text", + "options": [], + "timeout_s": 300, + "speak": false, + "source": "" +} +``` + +`kind` is `text`, `choice`, or `confirm`. Response: + +```json +{ "status": "answered", "answer": "..." } +``` + +`status` is `answered`, `dismissed`, or `timeout`. + +3. Fire-and-forget messages: POST `/v1/notify` with `{ "message": "...", "speak": true }`. 4. Journal: GET `/v1/answers?since=`. -Etiquette: one question at a time (server enforces it); short questions; -set `speak:true` only for time-sensitive asks; always set `source`. -CLI alternative (cron loops): `echo --ask "..." --ask-options "a,b" --ask-timeout 60`. +Etiquette: one question at a time (the server enforces it); keep questions short; +set `speak: true` only for time-sensitive asks; always set `source`. + +CLI alternative for cron loops: + +```bash +echo --ask "..." --ask-options "a,b" --ask-timeout 60 +```