From 15447d9d87105c7aa0bcddf04d8a48e9cf4e9f77 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 10:29:37 +0200 Subject: [PATCH 1/8] [codex/vc-workflow] fix: observe Layer 1 arming in corpus reports - derive profile observation from the replay provider signal instead of tail patch count - require every successful execution to match the requested profile - fail closed when no execution succeeds and cover mixed observations --- bin/codescribe-corpus.rs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/bin/codescribe-corpus.rs b/bin/codescribe-corpus.rs index 5a7b841a..e9a81674 100644 --- a/bin/codescribe-corpus.rs +++ b/bin/codescribe-corpus.rs @@ -1119,11 +1119,14 @@ async fn run_worker(args: WorkerArgs) -> Result<()> { let successful = rows.iter().filter(|row| row.status == "ok").count(); let failed = rows.len() - successful; let total_tail_patches = rows.iter().map(|row| row.tail_patches).sum(); - let observed_layered = total_tail_patches > 0; let successful_rows = rows .iter() .filter(|row| row.status == "ok") .collect::>(); + let (observed_layered, profile_observation_matches) = layering_observation( + args.profile.layered(), + successful_rows.iter().map(|row| row.layer1_provider_armed), + ); let mean_wer = mean(successful_rows.iter().map(|row| row.wer)); let mean_cer = mean(successful_rows.iter().map(|row| row.cer)); let mean_character_parity = mean(successful_rows.iter().map(|row| row.character_parity)); @@ -1173,7 +1176,7 @@ async fn run_worker(args: WorkerArgs) -> Result<()> { total_tail_patches, requested_layered: args.profile.layered(), observed_layered, - profile_observation_matches: observed_layered == args.profile.layered(), + profile_observation_matches, mean_wer, mean_cer, mean_character_parity, @@ -1189,6 +1192,24 @@ async fn run_worker(args: WorkerArgs) -> Result<()> { Ok(()) } +fn layering_observation( + requested_layered: bool, + provider_armed: impl IntoIterator, +) -> (bool, bool) { + let mut successful_executions = 0usize; + let mut observed_layered = false; + let mut every_execution_matches = true; + for armed in provider_armed { + successful_executions += 1; + observed_layered |= armed; + every_execution_matches &= armed == requested_layered; + } + ( + observed_layered, + successful_executions > 0 && every_execution_matches, + ) +} + fn publish_quality_audio(quality_audio_dir: &Path, clip: &Clip) -> Result { let extension = clip .path @@ -1880,6 +1901,16 @@ mod tests { } } + #[test] + fn layering_observation_reads_provider_arming_not_tail_patch_count() { + assert_eq!(layering_observation(true, [true, true]), (true, true)); + assert_eq!(layering_observation(false, [false, false]), (false, true)); + assert_eq!(layering_observation(true, [true, false]), (true, false)); + assert_eq!(layering_observation(false, [false, true]), (true, false)); + assert_eq!(layering_observation(true, []), (false, false)); + assert_eq!(layering_observation(false, []), (false, false)); + } + #[test] fn privacy_contract_is_fail_closed() { let contract = PrivacyContract::default(); From 563fcd27d02528baf25d7aca10ee37eec9ecd061 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 10:11:55 +0200 Subject: [PATCH 2/8] [codex/vc-workflow] feat: establish one transcript truth --- .env.debug.example | 6 + .env.example | 6 + app/controller/mod.rs | 63 +- app/controller/tests.rs | 2 + app/presentation/emitter.rs | 217 ++- app/presentation/mod.rs | 2 + app/presentation/transcript_bus.rs | 410 ++++++ bridge/src/hotkeys.rs | 385 +----- bridge/src/lib.rs | 2 +- bridge/src/recording.rs | 1179 +---------------- core/config/default_env.txt | 5 + docs/ENV_REGISTRY.toml | 18 +- docs/HOTKEYS_CONTRACT.md | 45 +- docs/STT_CONTRACT.md | 36 +- docs/TRANSCRIPT_BUS.md | 47 + macos/Codescribe/App.swift | 42 +- macos/Codescribe/Bridge/codescribe_ffi.swift | 589 -------- macos/Codescribe/Bridge/codescribe_ffiFFI.h | 162 --- macos/Codescribe/Core/AppModel.swift | 34 +- macos/Codescribe/Core/ComposerDictation.swift | 341 +---- .../Screens/AgentChat/AgentChatStore.swift | 151 +-- .../Screens/AgentChat/Composer.swift | 97 +- .../Screens/Settings/SettingsViewModel.swift | 4 +- .../Screens/Tray/RealTrayEngine.swift | 5 +- .../Codescribe/Screens/Tray/TrayEngine.swift | 2 +- macos/CodescribeTests/AgentSummonTests.swift | 15 - macos/CodescribeTests/ComposerMicTests.swift | 156 --- 27 files changed, 893 insertions(+), 3128 deletions(-) create mode 100644 app/presentation/transcript_bus.rs create mode 100644 docs/TRANSCRIPT_BUS.md diff --git a/.env.debug.example b/.env.debug.example index b3ada346..b40adf87 100644 --- a/.env.debug.example +++ b/.env.debug.example @@ -63,6 +63,12 @@ # SOUND_NAME=Tink # Default: Tink — macOS system sound name for beep # SOUND_VOLUME=1.0 # Default: 1.0 — Sound volume (0.0-1.0) +# ============================================================================= +# STORAGE / OBSERVABILITY +# ============================================================================= +# CODESCRIBE_TRANSCRIPT_BUS_PATH= # Default: unset — Override private committed transcript NDJSON bus path +# XDG_STATE_HOME= # Default: unset — Standard XDG state root used by the transcript bus + # ============================================================================= # STREAMING / BUFFERING # ============================================================================= diff --git a/.env.example b/.env.example index b79b78c7..d166d052 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,12 @@ # SOUND_NAME=Tink # Default: Tink — macOS system sound name for beep # SOUND_VOLUME=1.0 # Default: 1.0 — Sound volume (0.0-1.0) +# ============================================================================= +# STORAGE / OBSERVABILITY +# ============================================================================= +# CODESCRIBE_TRANSCRIPT_BUS_PATH= # Default: unset — Override private committed transcript NDJSON bus path +# XDG_STATE_HOME= # Default: unset — Standard XDG state root used by the transcript bus + # ============================================================================= # STREAMING / BUFFERING # ============================================================================= diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 8d5730a5..bcc45094 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -53,7 +53,7 @@ pub use helpers::{ pub use overlay_paste::{OverlayPasteDelivery, OverlayPasteResult}; pub use types::{HotkeyAction, HotkeyInput, HotkeyType, State, TranscriptionActionContractMode}; -use crate::presentation::emitter::PresentationEmitter; +use crate::presentation::{PresentationEmitter, TranscriptBus, TranscriptMode, TranscriptSession}; use anyhow::{Context, Result}; use std::path::PathBuf; use std::sync::Arc; @@ -720,23 +720,6 @@ impl RecordingController { } } - /// Capture the assistive trigger context (selection + frontmost app) for a - /// session whose microphone is owned by the Agent composer. The controller - /// start paths (`schedule_hold_start` / `start_toggle_recording`) never run - /// on that route, so without this arm the HOTKEYS_CONTRACT line "Selection - /// is captured in the trigger handler, never at send time" had no executor - /// on the primary assistive path and auto-send delivered the spoken text - /// alone (review P0-02). The bridge calls this exactly when a NEW agent - /// capture is about to start (capture owner still none). - pub async fn arm_assistive_trigger_context(&self) { - let context = tokio::task::spawn_blocking(capture_assistive_context) - .await - .unwrap_or_default(); - *self.pre_overlay_frontmost_app.write().await = context.frontmost_app.clone(); - *self.assistive_context.write().await = Some(context.clone()); - *self.pending_assistive_context.write().await = Some(context); - } - /// Deliver the overlay's current transcript with the context captured at /// trigger time. Taking the context makes delivery one-shot. pub async fn deliver_pending_assistive_transcript(&self, transcript: String) -> Result { @@ -1315,14 +1298,19 @@ impl RecordingController { preview_deltas_enabled: bool, event_broadcast: broadcast::Sender, session_telemetry: SharedSessionTelemetry, + transcript_bus: Option>, ) -> Arc { let delta_sink = preview_deltas_enabled.then(|| { Arc::new(helpers::RoutingDeltaSink) as Arc }); - let pe: Arc = Arc::new( - PresentationEmitter::new(transcript_buffer, delta_sink, None), - ); + let pe: Arc = + Arc::new(PresentationEmitter::new_with_transcript_bus( + transcript_buffer, + delta_sink, + None, + transcript_bus, + )); let ipc_sink: Arc = Arc::new(helpers::IpcBroadcastSink::new(event_broadcast)); let telemetry_sink: Arc = @@ -1370,6 +1358,7 @@ impl RecordingController { preview_deltas_enabled: bool, event_broadcast: broadcast::Sender, session_telemetry: SharedSessionTelemetry, + transcript_bus: Option>, ) { Self::configure_level_broadcast(recorder, event_broadcast.clone()); recorder.set_event_sink(Some(Self::build_recording_event_sink( @@ -1377,6 +1366,7 @@ impl RecordingController { preview_deltas_enabled, event_broadcast, session_telemetry, + transcript_bus, ))); } @@ -1388,6 +1378,7 @@ impl RecordingController { _flush_voice_chat_on_vad_end: bool, event_broadcast: broadcast::Sender, session_telemetry: SharedSessionTelemetry, + transcript_bus: Option>, ) { // Hands-off is ONE continuous recorder session (ADR 2026-05-28 Faza 1). // Normal hands-off uses cumulative SessionRendered deltas in the transcription overlay. @@ -1402,6 +1393,7 @@ impl RecordingController { preview_deltas_enabled, event_broadcast, session_telemetry, + transcript_bus, ))); } @@ -2231,6 +2223,15 @@ impl RecordingController { // so the very first deltas route to the correct overlay. set_assistive_session(is_assistive); reset_session_telemetry(&session_telemetry); + let transcript_bus = TranscriptBus::open(TranscriptSession { + session_id: new_session_id, + mode: if is_assistive { + TranscriptMode::Assistive + } else { + TranscriptMode::Dictation + }, + }) + .map(Arc::new); // Runtime pipeline is always event-based. Hold mode has no utterance callback; // text is finalized on key-up in `finish_recording`. @@ -2239,6 +2240,7 @@ impl RecordingController { is_assistive || overlay_enabled, event_broadcast.clone(), Arc::clone(&session_telemetry), + transcript_bus.clone(), ); rec.configure_layer1( &UserSettings::load(), @@ -2262,6 +2264,7 @@ impl RecordingController { is_assistive || overlay_enabled, event_broadcast.clone(), Arc::clone(&session_telemetry), + transcript_bus.clone(), ); let retry_result = rec.start_event_session(language_hint).await; if let Err(retry_err) = retry_result { @@ -2281,6 +2284,10 @@ impl RecordingController { } } + if let Some(bus) = &transcript_bus { + bus.publish_started(); + } + if hold_start_generation.load(Ordering::SeqCst) != task_generation { warn!("Hold-start superseded after recorder start; stopping stale session"); if rec.recorder.is_active() @@ -2430,6 +2437,15 @@ impl RecordingController { // so the very first deltas route to the correct overlay. set_assistive_session(is_assistive); reset_session_telemetry(&self.session_telemetry); + let transcript_bus = TranscriptBus::open(TranscriptSession { + session_id: new_session_id, + mode: if is_assistive { + TranscriptMode::Agent + } else { + TranscriptMode::Dictation + }, + }) + .map(Arc::new); // Runtime pipeline is always event-based. Self::configure_toggle_event_sink( @@ -2438,6 +2454,7 @@ impl RecordingController { is_assistive, self.event_broadcast.clone(), Arc::clone(&self.session_telemetry), + transcript_bus.clone(), ); recorder.configure_layer1( &UserSettings::load(), @@ -2463,6 +2480,7 @@ impl RecordingController { is_assistive, self.event_broadcast.clone(), Arc::clone(&self.session_telemetry), + transcript_bus.clone(), ); if let Err(retry_err) = recorder.start_event_session(language_hint).await { drop(recorder_guard); @@ -2478,6 +2496,9 @@ impl RecordingController { return Err(e); } } + if let Some(bus) = &transcript_bus { + bus.publish_started(); + } drop(recorder_guard); // Transition to REC_TOGGLE immediately after recorder starts. diff --git a/app/controller/tests.rs b/app/controller/tests.rs index 6d691649..922c05f1 100644 --- a/app/controller/tests.rs +++ b/app/controller/tests.rs @@ -5149,6 +5149,7 @@ async fn hold_event_sink_forwards_live_preview_then_final_in_order() { true, controller.event_broadcast.clone(), Arc::clone(&controller.session_telemetry), + None, ); sink.on_event(&EngineEvent::Preview { @@ -5187,6 +5188,7 @@ async fn late_correction_after_final_is_a_single_patch_event_not_a_second_final( true, controller.event_broadcast.clone(), Arc::clone(&controller.session_telemetry), + None, ); sink.on_event(&EngineEvent::Preview { diff --git a/app/presentation/emitter.rs b/app/presentation/emitter.rs index 27a2ca93..51aaf8c2 100644 --- a/app/presentation/emitter.rs +++ b/app/presentation/emitter.rs @@ -14,6 +14,8 @@ use codescribe_core::pipeline::streaming::BufferedEmitter; use tokio::sync::Mutex; use tracing::{debug, info}; +use super::transcript_bus::{CommittedTranscript, TranscriptBus}; + /// Commands sent through the ordered channel to the emitter worker. enum EmitterCmd { SetTargetText(String), @@ -45,6 +47,20 @@ struct TranscriptUtteranceRecord { segments: Vec, } +impl TranscriptUtteranceRecord { + /// Narrow the reducer's internal record to the clean public bus contract. + /// `raw_text` is deliberately excluded at this boundary. + fn clean_transcript(&self) -> CommittedTranscript { + CommittedTranscript { + utterance_id: self.utterance_id, + text: self.text.clone(), + start_seconds: self.start_ts, + end_seconds: self.end_ts, + segments: self.segments.clone(), + } + } +} + /// Source of truth for the session transcript: everything already committed, /// plus the in-flight preview tail. /// @@ -101,7 +117,7 @@ impl TranscriptReducer { /// correction falls through to the preview path — treating it as new /// content. Without the search, a late correction to a non-tail utterance /// would append a duplicate instead of fixing the original. - fn apply_correction(&mut self, previous_text: &str, text: &str) { + fn apply_correction(&mut self, previous_text: &str, text: &str) -> Option { let previous = normalize_transcript_fragment(previous_text); let corrected = normalize_transcript_fragment(text); @@ -111,15 +127,16 @@ impl TranscriptReducer { // Only falls back to preview-append if no match found (new content). if self.active_preview.is_empty() { // Fast path + P3-03: search from tail (last first). Collapsed if for clippy. - for rec in self.committed.iter_mut().rev() { + for (index, rec) in self.committed.iter_mut().enumerate().rev() { if normalize_transcript_fragment(&rec.text) == previous { rec.text = corrected; - return; + return Some(index); } } } self.apply_preview(&corrected); + None } /// Test helper: delete chars from the live preview tail only. @@ -282,7 +299,9 @@ impl TranscriptReducer { text, previous_text, .. - } => self.apply_correction(previous_text, text), + } => { + let _ = self.apply_correction(previous_text, text); + } EngineEvent::UtteranceFinal { utterance_id, text, @@ -359,6 +378,8 @@ pub struct PresentationEmitter { session_state: std::sync::Mutex, /// Controls what the delta sink sees: full session text or only the live preview. delta_render_mode: DeltaRenderMode, + /// Durable observer of this exact reducer's committed/final truth. + transcript_bus: Option>, } impl PresentationEmitter { @@ -374,6 +395,17 @@ impl PresentationEmitter { transcript_buffer: Arc>, delta_callback: Option>, stream_log_path: Option, + ) -> Self { + Self::new_with_transcript_bus(transcript_buffer, delta_callback, stream_log_path, None) + } + + /// Build an emitter observed by the clean transcript bus. The bus sees the + /// same reducer mutation as paste/history and never reconstructs UI deltas. + pub fn new_with_transcript_bus( + transcript_buffer: Arc>, + delta_callback: Option>, + stream_log_path: Option, + transcript_bus: Option>, ) -> Self { let emitter = Arc::new(Mutex::new(BufferedEmitter::new( transcript_buffer, @@ -434,6 +466,7 @@ impl PresentationEmitter { vad_start_emitted: std::sync::atomic::AtomicBool::new(false), session_state: std::sync::Mutex::new(TranscriptReducer::default()), delta_render_mode: DeltaRenderMode::SessionRendered, + transcript_bus, } } @@ -545,22 +578,53 @@ impl EventSink for PresentationEmitter { }; self.send_cmd(EmitterCmd::SetTargetText(rendered)); } - EngineEvent::Correction { .. } => { - let rendered = { + EngineEvent::Correction { + text, + previous_text, + .. + } => { + let (rendered, revised) = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - let _ = state.apply_event(event); - match self.delta_render_mode { + let revised = state + .apply_correction(previous_text, text) + .and_then(|index| state.committed.get(index)) + .map(TranscriptUtteranceRecord::clean_transcript); + let rendered = match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), - } + }; + (rendered, revised) }; + if let (Some(bus), Some(revised)) = (&self.transcript_bus, revised) { + bus.publish_utterance("utterance_revised", revised); + } self.send_cmd(EmitterCmd::SetTargetText(rendered)); } EngineEvent::UtteranceFinal { utterance_id, .. } => { - let callback_payload = { + let (callback_payload, committed, revised) = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.apply_event(event) + let existed = state + .committed + .iter() + .any(|record| record.utterance_id == *utterance_id); + let callback_payload = state.apply_event(event); + let committed = state + .committed + .iter() + .rfind(|record| record.utterance_id == *utterance_id) + .map(TranscriptUtteranceRecord::clean_transcript); + (callback_payload, committed, existed) }; + if let (Some(bus), Some(committed)) = (&self.transcript_bus, committed) { + bus.publish_utterance( + if revised { + "utterance_revised" + } else { + "utterance_committed" + }, + committed, + ); + } if let Some(cb) = &self.utterance_callback && let Some(payload) = callback_payload { @@ -645,6 +709,15 @@ impl EventSink for PresentationEmitter { state.rendered_text() }; self.send_cmd(EmitterCmd::SetTargetText(rendered)); + if let Some(bus) = &self.transcript_bus { + bus.publish_final( + self.session_state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .streaming_floor(), + None, + ); + } // Stats is the last event from transcription_session. // Signal BufferedEmitter to finish through the ordered channel, // ensuring all pending pushes are processed first. @@ -658,22 +731,36 @@ impl EventSink for PresentationEmitter { // (transcript_buffer → paste/history) that the overlay already // received, so phase-1 layered patches don't diverge between the // two sinks. Only re-render when the buffer actually changed. - let rendered = { + let (rendered, revised) = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); if state.apply_layered_patch(event) { - Some(match self.delta_render_mode { + let rendered = Some(match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), - }) + }); + let utterance_id = match event { + EngineEvent::ReplaceRange { utterance_id, .. } + | EngineEvent::InsertAnnotation { utterance_id, .. } => *utterance_id, + _ => unreachable!(), + }; + let revised = state + .committed + .iter() + .rfind(|record| record.utterance_id == utterance_id) + .map(TranscriptUtteranceRecord::clean_transcript); + (rendered, revised) } else { - None + (None, None) } }; + if let (Some(bus), Some(revised)) = (&self.transcript_bus, revised) { + bus.publish_utterance("utterance_revised", revised); + } if let Some(rendered) = rendered { self.send_cmd(EmitterCmd::SetTargetText(rendered)); } } - EngineEvent::SessionFinalised { .. } => { + EngineEvent::SessionFinalised { session_id, .. } => { // The Apple progressive lane closes with SessionFinalised and // does not emit Stats. Persist only immutable canvas here: a // cumulative final can re-state committed text as the last @@ -684,6 +771,9 @@ impl EventSink for PresentationEmitter { state.clear_live_preview(); state.streaming_floor() }; + if let Some(bus) = &self.transcript_bus { + bus.publish_final(rendered.clone(), Some(session_id.clone())); + } self.send_cmd(EmitterCmd::SetTargetText(rendered)); self.send_cmd(EmitterCmd::Finish); } @@ -1366,4 +1456,99 @@ mod tests { // No duplication of the corrected text. assert_eq!(snapshot.matches("Ala ma").count(), 1); } + + /// Dictation and Agent differ only in metadata/consumer choice. The exact + /// same engine fixture must produce byte-equivalent committed truth and an + /// Agent utterance event before the stop-time session final. + #[tokio::test] + async fn dictation_and_agent_publish_identical_committed_events_before_consumers() { + use crate::presentation::transcript_bus::{ + CleanTranscriptEvent, TranscriptBus, TranscriptMode, TranscriptSession, + }; + + fn run_route( + root: &std::path::Path, + mode: TranscriptMode, + session_id: &str, + ) -> Vec { + let path = root.join(format!("{mode:?}.jsonl")); + let bus = Arc::new( + TranscriptBus::open_at( + TranscriptSession { + session_id: session_id.to_string(), + mode, + }, + path.clone(), + Some(48_000), + ) + .unwrap(), + ); + let transcript = Arc::new(Mutex::new(String::new())); + let emitter = + PresentationEmitter::new_with_transcript_bus(transcript, None, None, Some(bus)); + emitter.on_event(&EngineEvent::Preview { + rev: 1, + text: "shared clean truth".to_string(), + }); + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 42, + text: "shared clean truth".to_string(), + raw_text: "unpublished raw hypothesis".to_string(), + start_ts: 0.25, + end_ts: 1.5, + segments: vec![TranscriptSegment { + text: "shared clean truth".to_string(), + start_ts: 0.25, + end_ts: 1.5, + }], + vad_speech_pct: Some(91.0), + avg_logprob: Some(-0.2), + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + emitter.on_event(&EngineEvent::SessionFinalised { + session_id: format!("pipeline-{session_id}"), + layer_summary: LayerSummary::default(), + }); + + std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + let temp = tempfile::tempdir().unwrap(); + let dictation = run_route(temp.path(), TranscriptMode::Dictation, "dictation-session"); + let agent = run_route(temp.path(), TranscriptMode::Agent, "agent-session"); + + let comparable = |events: &[CleanTranscriptEvent]| { + events + .iter() + .skip(1) + .map(|event| { + ( + event.status.clone(), + event.utterance_id, + event.sample_rate_hz, + event.sample_start, + event.sample_end, + event.audio_start_seconds, + event.audio_end_seconds, + event.text.clone(), + event.segments.clone(), + ) + }) + .collect::>() + }; + assert_eq!(comparable(&dictation), comparable(&agent)); + assert_eq!(agent[1].status, "utterance_committed"); + assert_eq!(agent[2].status, "session_finalized"); + assert!( + !agent + .iter() + .any(|event| event.text.contains("unpublished raw")) + ); + } } diff --git a/app/presentation/mod.rs b/app/presentation/mod.rs index e170890a..9cd2ae5b 100644 --- a/app/presentation/mod.rs +++ b/app/presentation/mod.rs @@ -5,5 +5,7 @@ //! and this module decides how to show them. pub mod emitter; +pub mod transcript_bus; pub use emitter::PresentationEmitter; +pub use transcript_bus::{TranscriptBus, TranscriptMode, TranscriptSession}; diff --git a/app/presentation/transcript_bus.rs b/app/presentation/transcript_bus.rs new file mode 100644 index 00000000..81dd589c --- /dev/null +++ b/app/presentation/transcript_bus.rs @@ -0,0 +1,410 @@ +//! Durable clean transcript events for operator and control-plane consumers. +//! +//! The bus is an observer of the committed [`PresentationEmitter`] reducer. It +//! never opens audio, re-transcribes a file, or reconstructs text from UI +//! deltas. One append-only JSON object is flushed per state transition. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use chrono::{SecondsFormat, Utc}; +use codescribe_core::pipeline::contracts::TranscriptSegment; +use serde::{Deserialize, Serialize}; + +/// Explicit path override for the clean transcript bus. +pub const TRANSCRIPT_BUS_PATH_ENV: &str = "CODESCRIBE_TRANSCRIPT_BUS_PATH"; +/// Stable filename under the configured state/data root. +pub const TRANSCRIPT_BUS_FILENAME: &str = "transcript-events.jsonl"; + +/// Product mode attached to every committed transcript event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TranscriptMode { + /// Plain dictation or formatting; the downstream action is paste/format. + Dictation, + /// Right Option / composer Agent voice input; the downstream action is send. + Agent, + /// Hold-based Chat/Selection assistance; downstream action is Agent delivery. + Assistive, +} + +/// Immutable identity supplied by the controller before capture starts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TranscriptSession { + pub session_id: String, + pub mode: TranscriptMode, +} + +/// One utterance after it has entered the authoritative committed reducer. +#[derive(Debug, Clone, PartialEq)] +pub struct CommittedTranscript { + pub utterance_id: u64, + pub text: String, + pub start_seconds: f32, + pub end_seconds: f32, + pub segments: Vec, +} + +/// Append-only public event contract. `text` is always clean reducer truth; +/// unfiltered engine `raw_text` never crosses this boundary. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CleanTranscriptEvent { + pub schema: String, + pub sequence: u64, + pub session_id: String, + pub mode: TranscriptMode, + pub utterance_id: Option, + pub emitted_at: String, + pub status: String, + pub sample_rate_hz: Option, + pub sample_start: Option, + pub sample_end: Option, + pub audio_start_seconds: Option, + pub audio_end_seconds: Option, + pub text: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub segments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub pipeline_session_id: Option, +} + +/// Synchronous low-frequency writer. Commits occur on the STT worker, not the +/// CoreAudio callback, and each line is flushed so a live tailer sees it before +/// the next utterance or process exit. +pub struct TranscriptBus { + session: TranscriptSession, + path: PathBuf, + writer: Mutex, + sample_rate_override: Option, +} + +/// One lock owns lifecycle and bytes together. This makes the sequence stored +/// on disk authoritative even when engine-close and a late reducer callback +/// arrive from different threads. +struct TranscriptBusWriter { + file: File, + sequence: u64, + started: bool, + finalized: bool, +} + +impl TranscriptBus { + /// Resolve the production path and open the session bus. Failure disables + /// only observability; it must never stop microphone capture or delivery. + pub fn open(session: TranscriptSession) -> Option { + let path = transcript_bus_path(); + match Self::open_at(session, path, None) { + Ok(bus) => Some(bus), + Err(error) => { + tracing::warn!(%error, "clean transcript bus unavailable"); + None + } + } + } + + /// Open an explicit path. Kept public for deterministic pipeline tests and + /// embedders that already own an XDG/project state root. + pub fn open_at( + session: TranscriptSession, + path: PathBuf, + sample_rate_override: Option, + ) -> io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let mut options = OpenOptions::new(); + options.create(true).append(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + + let bus = Self { + session, + path, + writer: Mutex::new(TranscriptBusWriter { + file, + sequence: 0, + started: false, + finalized: false, + }), + sample_rate_override, + }; + Ok(bus) + } + + /// Publish the recording start exactly once. Controllers call this only + /// after audio starts; commit/final observers also call it defensively so + /// the first visible transcript event can never precede its session start. + pub fn publish_started(&self) { + let mut writer = self + .writer + .lock() + .unwrap_or_else(|error| error.into_inner()); + match self.ensure_started_locked(&mut writer) { + Ok(true) => { + tracing::info!(path = %self.path.display(), session_id = %self.session.session_id, mode = ?self.session.mode, "clean transcript bus session started"); + } + Ok(false) => {} + Err(error) => self.log_write_error(error), + } + } + + /// Publish a new committed slot or a later bounded revision of that slot. + pub fn publish_utterance(&self, status: &'static str, utterance: CommittedTranscript) { + let sample_rate = self.sample_rate(); + let event = CleanTranscriptEvent { + schema: "codescribe.transcript.v1".to_string(), + sequence: 0, + session_id: String::new(), + mode: self.session.mode, + utterance_id: Some(utterance.utterance_id), + emitted_at: String::new(), + status: status.to_string(), + sample_rate_hz: sample_rate, + sample_start: sample_rate.map(|rate| seconds_to_sample(utterance.start_seconds, rate)), + sample_end: sample_rate.map(|rate| seconds_to_sample(utterance.end_seconds, rate)), + audio_start_seconds: Some(utterance.start_seconds), + audio_end_seconds: Some(utterance.end_seconds), + text: utterance.text, + segments: utterance.segments, + pipeline_session_id: None, + }; + + let mut writer = self + .writer + .lock() + .unwrap_or_else(|error| error.into_inner()); + if writer.finalized { + tracing::warn!(session_id = %self.session.session_id, %status, "clean transcript event ignored after session finalization"); + return; + } + if let Err(error) = self + .ensure_started_locked(&mut writer) + .and_then(|_| self.write_event_locked(&mut writer, event)) + { + self.log_write_error(error); + } + } + + /// Publish the immutable session canvas at the engine close boundary. + pub fn publish_final(&self, text: String, pipeline_session_id: Option) { + let mut writer = self + .writer + .lock() + .unwrap_or_else(|error| error.into_inner()); + if writer.finalized { + return; + } + let event = CleanTranscriptEvent { + schema: "codescribe.transcript.v1".to_string(), + sequence: 0, + session_id: String::new(), + mode: self.session.mode, + utterance_id: None, + emitted_at: String::new(), + status: "session_finalized".to_string(), + sample_rate_hz: self.sample_rate(), + sample_start: None, + sample_end: None, + audio_start_seconds: None, + audio_end_seconds: None, + text, + segments: Vec::new(), + pipeline_session_id, + }; + match self + .ensure_started_locked(&mut writer) + .and_then(|_| self.write_event_locked(&mut writer, event)) + { + Ok(()) => writer.finalized = true, + Err(error) => self.log_write_error(error), + } + } + + /// The resolved path consumed by an external NDJSON tailer. + pub fn path(&self) -> &Path { + &self.path + } + + fn sample_rate(&self) -> Option { + self.sample_rate_override.or_else(|| { + codescribe_core::audio::capture_receipt::last_open_capture_path() + .map(|capture| capture.sample_rate) + .filter(|rate| *rate > 0) + }) + } + + fn ensure_started_locked(&self, writer: &mut TranscriptBusWriter) -> io::Result { + if writer.started { + return Ok(false); + } + self.write_event_locked( + writer, + CleanTranscriptEvent { + schema: "codescribe.transcript.v1".to_string(), + sequence: 0, + session_id: String::new(), + mode: self.session.mode, + utterance_id: None, + emitted_at: String::new(), + status: "session_started".to_string(), + sample_rate_hz: None, + sample_start: None, + sample_end: None, + audio_start_seconds: None, + audio_end_seconds: None, + text: String::new(), + segments: Vec::new(), + pipeline_session_id: None, + }, + )?; + writer.started = true; + Ok(true) + } + + fn write_event_locked( + &self, + writer: &mut TranscriptBusWriter, + mut event: CleanTranscriptEvent, + ) -> io::Result<()> { + let next_sequence = writer.sequence.saturating_add(1); + event.sequence = next_sequence; + event.session_id.clone_from(&self.session.session_id); + event.mode = self.session.mode; + event.emitted_at = Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true); + + let mut encoded = serde_json::to_vec(&event).map_err(io::Error::other)?; + encoded.push(b'\n'); + writer.file.write_all(&encoded)?; + writer.file.flush()?; + writer.sequence = next_sequence; + Ok(()) + } + + fn log_write_error(&self, error: io::Error) { + tracing::warn!(%error, path = %self.path.display(), "clean transcript event write failed"); + } +} + +/// Path precedence: explicit contract, XDG state, then Codescribe's existing +/// project/data override (`CODESCRIBE_DATA_DIR`) via `Config::config_dir()`. +pub fn transcript_bus_path() -> PathBuf { + if let Ok(path) = std::env::var(TRANSCRIPT_BUS_PATH_ENV) { + let path = path.trim(); + if !path.is_empty() { + return expand_tilde(path); + } + } + if let Ok(root) = std::env::var("XDG_STATE_HOME") { + let root = root.trim(); + if !root.is_empty() { + return expand_tilde(root) + .join("codescribe") + .join(TRANSCRIPT_BUS_FILENAME); + } + } + codescribe_core::config::Config::config_dir().join(TRANSCRIPT_BUS_FILENAME) +} + +fn seconds_to_sample(seconds: f32, sample_rate: u32) -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + (f64::from(seconds) * f64::from(sample_rate)).round() as u64 +} + +fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + return directories::BaseDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from(path)); + } + if let Some(relative) = path.strip_prefix("~/") { + return directories::BaseDirs::new() + .map(|dirs| dirs.home_dir().join(relative)) + .unwrap_or_else(|| PathBuf::from(path)); + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bus_flushes_start_commit_and_final_as_private_ndjson() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("events.jsonl"); + let bus = TranscriptBus::open_at( + TranscriptSession { + session_id: "session-agent".to_string(), + mode: TranscriptMode::Agent, + }, + path.clone(), + Some(48_000), + ) + .unwrap(); + bus.publish_utterance( + "utterance_committed", + CommittedTranscript { + utterance_id: 7, + text: "clean final".to_string(), + start_seconds: 0.25, + end_seconds: 1.5, + segments: Vec::new(), + }, + ); + bus.publish_final( + "clean final".to_string(), + Some("engine-session".to_string()), + ); + bus.publish_utterance( + "utterance_revised", + CommittedTranscript { + utterance_id: 7, + text: "must not escape finalization".to_string(), + start_seconds: 0.25, + end_seconds: 1.5, + segments: Vec::new(), + }, + ); + bus.publish_final("duplicate final".to_string(), None); + + let lines: Vec = std::fs::read_to_string(&path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].status, "session_started"); + assert_eq!(lines[1].status, "utterance_committed"); + assert_eq!(lines[1].sample_start, Some(12_000)); + assert_eq!(lines[1].sample_end, Some(72_000)); + assert_eq!(lines[2].status, "session_finalized"); + assert_eq!(lines[2].text, "clean final"); + assert_eq!( + lines.iter().map(|event| event.sequence).collect::>(), + vec![1, 2, 3] + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } +} diff --git a/bridge/src/hotkeys.rs b/bridge/src/hotkeys.rs index 3d896ad8..56e37bb8 100644 --- a/bridge/src/hotkeys.rs +++ b/bridge/src/hotkeys.rs @@ -45,121 +45,47 @@ type SharedAppActionListener = Arc>>> pub trait CsAppActionListener: Send + Sync { /// Bring the Agent surface forward. UI-only — must not touch the mic. fn on_show_agent(&self); - /// Drive the Agent-owned composer microphone. The bridge has already claimed - /// (or verified) capture ownership before this fires. - fn on_agent_capture(&self, command: CsAgentCaptureCommand); -} - -/// UI commands for the Agent-owned composer microphone. Assistive hotkeys are -/// translated here, before the legacy RecordingController can prepare/show its -/// overlay, so there is exactly one Assistive capture owner. -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum CsAgentCaptureCommand { - Start, - Stop, - Toggle, } /// Capture ownership sentinel: no lane currently owns the microphone. const CAPTURE_OWNER_NONE: u8 = 0; -/// Capture ownership: the legacy overlay / `RecordingController` owns the mic. -const CAPTURE_OWNER_OVERLAY: u8 = 1; -/// Capture ownership: the Agent composer microphone owns the mic. -const CAPTURE_OWNER_AGENT: u8 = 2; -/// Process-wide exclusive capture owner (overlay vs Agent). Atomic so hotkey -/// and FFI paths can claim/release without holding a heavier lock. +/// Capture ownership: the one shared `RecordingController` owns the mic. +const CAPTURE_OWNER_CONTROLLER: u8 = 1; +/// Process-wide start gate. Every Dictation/Agent/Assistive gesture enters the +/// same controller, so this protects one capture rather than mediating lanes. static CAPTURE_OWNER: AtomicU8 = AtomicU8::new(CAPTURE_OWNER_NONE); -/// Try to become the single process-wide capture owner for the Agent lane. -/// -/// Returns true when the Agent now owns the mic — including the re-entrant case -/// where it already did (a repeated Start must not be treated as a conflict). -/// Returns false only when the legacy overlay holds ownership. -fn claim_agent_capture() -> bool { - match CAPTURE_OWNER.compare_exchange( - CAPTURE_OWNER_NONE, - CAPTURE_OWNER_AGENT, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) | Err(CAPTURE_OWNER_AGENT) => true, - Err(_) => false, - } -} - -/// Release Agent capture ownership. Compare-exchange rather than a plain store, -/// so a late Stop can never steal ownership away from the overlay. -fn release_agent_capture() { - let _ = CAPTURE_OWNER.compare_exchange( - CAPTURE_OWNER_AGENT, - CAPTURE_OWNER_NONE, - Ordering::AcqRel, - Ordering::Acquire, - ); -} - -/// Whether this event would begin a NEW overlay capture session, and therefore +/// Whether this event would begin a NEW controller capture session, and therefore /// has to claim capture ownership first. Deliberately narrow: only the two -/// toggles and a raw hold key-down start a session; every other event either +/// toggles and a hold key-down start a session; every other event either /// continues or ends one that already owns the mic. -fn event_can_start_overlay(event: &HotkeyEvent) -> bool { +fn event_can_start_capture(event: &HotkeyEvent) -> bool { matches!( event, HotkeyEvent::ToggleNormal | HotkeyEvent::ToggleRaw + | HotkeyEvent::ToggleAssistive | HotkeyEvent::Hold { action: HoldAction::Down, - mode: HoldMode::Raw, + mode: HoldMode::Raw | HoldMode::Chat | HoldMode::Selection, } ) } -/// Translate an assistive-lane hotkey into an Agent composer command, or `None` -/// when the event belongs to the recording controller instead. -/// -/// This is the fork that keeps exactly one Assistive capture owner: Chat and -/// Selection hold modes plus the assistive toggle are Agent-owned, everything -/// else falls through to `RecordingController`. -fn agent_capture_command(event: &HotkeyEvent) -> Option { - match event { - HotkeyEvent::ToggleAssistive => Some(CsAgentCaptureCommand::Toggle), - HotkeyEvent::Hold { - action: HoldAction::Down, - mode: HoldMode::Chat | HoldMode::Selection, - } - | HotkeyEvent::HoldUpdate { - mode: HoldMode::Chat | HoldMode::Selection, - } => Some(CsAgentCaptureCommand::Start), - HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Chat | HoldMode::Selection, - } => Some(CsAgentCaptureCommand::Stop), - _ => None, - } -} - -/// A Shift upgrade can arrive after raw hold capture already started. Ownership -/// cannot migrate mid-recording: keep the existing overlay session raw and make -/// sure its eventual key-up still reaches the controller that owns the mic. -fn overlay_owned_assistive_hold_fallback(event: &HotkeyEvent) -> Option { - if CAPTURE_OWNER.load(Ordering::Acquire) != CAPTURE_OWNER_OVERLAY { - return None; - } - match event { - HotkeyEvent::HoldUpdate { - mode: HoldMode::Chat | HoldMode::Selection, - } => Some(HotkeyEvent::HoldUpdate { - mode: HoldMode::Raw, - }), - HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Chat | HoldMode::Selection, - } => Some(HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Raw, - }), - _ => None, - } +/// Agent/Assistive recording still fronts the Agent surface, but the UI callback +/// is notification only; audio and transcript events continue to the controller. +fn event_targets_agent_ui(event: &HotkeyEvent) -> bool { + matches!( + event, + HotkeyEvent::ToggleAssistive + | HotkeyEvent::Hold { + mode: HoldMode::Chat | HoldMode::Selection, + .. + } + | HotkeyEvent::HoldUpdate { + mode: HoldMode::Chat | HoldMode::Selection, + } + ) } /// Process-global slot for the lazily-created `RecordingController`. @@ -198,53 +124,26 @@ fn current_app_action_listener() -> Option> { /// callbacks so the whole contract is unit-testable without a live tap, /// controller or runtime. /// -/// Precedence is deliberate and must not be reordered: -/// 1. overlay-owned assistive fallback — ownership cannot migrate mid-recording; -/// 2. Agent capture commands — arming the trigger context BEFORE the composer -/// mic takes over, per `docs/HOTKEYS_CONTRACT.md`; -/// 3. UI-only commands (`ShowAgent`, `InsertHere`); -/// 4. everything else → the recording controller. -fn route_hotkey_event( +/// Agent/Assistive events notify the Agent window and then continue through the +/// same recording callback as Dictation. Only `ShowAgent` and `InsertHere` are +/// UI-only commands. +fn route_hotkey_event( event: HotkeyEvent, app_action_listener: Option>, dispatch_recording: F, dispatch_deferred_insert: G, - arm_assistive_trigger: H, ) where F: FnOnce(HotkeyEvent), G: FnOnce(), - H: FnOnce(), { - if let Some(fallback) = overlay_owned_assistive_hold_fallback(&event) { - if matches!(event, HotkeyEvent::HoldUpdate { .. }) { - notifications::notify( - "Codescribe", - "Finish Dictation before starting Agent voice input", - ); - } - dispatch_recording(fallback); - return; - } - if let Some(command) = agent_capture_command(&event) { + if event_targets_agent_ui(&event) { tracing::info!( - ?command, - "Assistive command: dispatching Agent-owned capture" + ?event, + "Assistive command: dispatching shared controller capture" ); - // HOTKEYS_CONTRACT: "Selection is captured in the trigger handler, - // never at send time." A capture owner of NONE means this command is - // about to START a new agent capture — arm the trigger context now, - // before the composer mic takes over. Stop/toggle-stop commands find - // the owner already AGENT and must not re-capture at send time. - if CAPTURE_OWNER.load(Ordering::Acquire) == CAPTURE_OWNER_NONE { - arm_assistive_trigger(); - } - if let Some(listener) = app_action_listener { - listener.on_agent_capture(command); - } else { - tracing::warn!("Assistive command rejected: Agent action listener unavailable"); - notifications::notify("Codescribe", "Agent microphone is unavailable"); + if let Some(listener) = app_action_listener.as_ref() { + listener.on_show_agent(); } - return; } match event { HotkeyEvent::ShowAgent => { @@ -339,7 +238,7 @@ pub(crate) fn refresh_live_controller_config() { } /// Pump the controller's broadcast stream into the registered Swift listener for -/// the controller's lifetime, and release overlay capture ownership on every +/// the controller's lifetime, and release controller capture ownership on every /// return to `idle`. /// /// The listener is resolved per event rather than captured, so a listener that @@ -372,7 +271,7 @@ fn spawn_event_forwarder(controller: Arc, handle: Handle) { IpcEventPayload::StateChange { to, .. } if to == "idle" ) { let _ = CAPTURE_OWNER.compare_exchange( - CAPTURE_OWNER_OVERLAY, + CAPTURE_OWNER_CONTROLLER, CAPTURE_OWNER_NONE, Ordering::AcqRel, Ordering::Acquire, @@ -647,11 +546,8 @@ impl CodescribeHotkeys { std::thread::spawn(move || { for event in rx { let spawn_handle = handle.clone(); - let arm_handle = handle.clone(); let controller_handle = handle.clone(); - let arm_controller_handle = handle.clone(); let controller_store = Arc::clone(&controller_store); - let arm_controller_store = Arc::clone(&controller_store); route_hotkey_event( event, current_app_action_listener(), @@ -665,22 +561,13 @@ impl CodescribeHotkeys { ) .await; if let Err(error) = dispatch { - if CAPTURE_OWNER.load(Ordering::Acquire) != CAPTURE_OWNER_AGENT { - tray_status::update_tray_status(TrayStatus::Error); - } + tray_status::update_tray_status(TrayStatus::Error); notifications::notify("Codescribe", &error.to_string()); eprintln!("Hotkey event error: {error}"); } }); }, deliver_deferred_insert_and_notify, - move || { - arm_handle.spawn(async move { - let controller = - ensure_controller(&arm_controller_store, arm_controller_handle); - controller.arm_assistive_trigger_context().await; - }); - }, ); } }); @@ -743,45 +630,12 @@ impl CodescribeHotkeys { /// Start the same toggle recording flow used by the default hotkey. pub async fn start_recording(&self) -> Result<(), CsError> { - if CAPTURE_OWNER.load(Ordering::Acquire) == CAPTURE_OWNER_AGENT { - return Err(CsError::Recording { - msg: "Agent voice input already owns the microphone".to_string(), - }); - } start_recording_with_event(HotkeyEvent::ToggleNormal).await } /// Start the same toggle flow in the assistive lane for UI-initiated recording. pub async fn start_assistive_recording(&self) -> Result<(), CsError> { - let Some(listener) = current_app_action_listener() else { - return Err(CsError::Recording { - msg: "Agent action listener unavailable".to_string(), - }); - }; - listener.on_agent_capture(CsAgentCaptureCommand::Toggle); - Ok(()) - } - - /// Atomically claim/release the one process-wide capture owner. Returns - /// false when the legacy overlay already owns the microphone. - pub fn set_agent_capture_active(&self, active: bool) -> bool { - let owns_capture = if active { - claim_agent_capture() - } else { - release_agent_capture(); - true - }; - if active && !owns_capture { - tracing::warn!("Agent capture rejected: transcription overlay owns the microphone"); - return false; - } - if active { - tray_status::set_tray_indicator_mode(BadgeMode::Assistive); - tray_status::update_tray_status(TrayStatus::Listening); - } else if tray_status::current_tray_status() == TrayStatus::Listening { - tray_status::update_tray_status(TrayStatus::Idle); - } - true + start_recording_with_event(HotkeyEvent::ToggleAssistive).await } /// Stop the active legacy-controller recording flow, if one is live. @@ -1036,10 +890,9 @@ async fn start_recording_with_event(event: HotkeyEvent) -> Result<(), CsError> { }) } -/// Wrap a recording dispatch in the full capture-ownership lifecycle: claim on a -/// session-starting event, refuse while the Agent owns the mic, show the -/// optimistic overlay, dispatch, compensate an orphaned "preparing", and release -/// ownership once the controller is back at `Idle`. +/// Wrap a recording dispatch in the one-controller capture lifecycle: claim on +/// a session-starting event, dispatch, compensate an orphaned "preparing", and +/// release ownership once the controller is back at `Idle`. /// /// The claim happens BEFORE any controller work so two racing gestures cannot /// both believe they started a session. @@ -1048,23 +901,16 @@ async fn dispatch_recording_with_capture_gate( controller: Arc, ) -> anyhow::Result<()> { let state_before = controller.current_state().await; - let starts_overlay = state_before == State::Idle && event_can_start_overlay(&event); - if starts_overlay { + let starts_capture = state_before == State::Idle && event_can_start_capture(&event); + if starts_capture { CAPTURE_OWNER .compare_exchange( CAPTURE_OWNER_NONE, - CAPTURE_OWNER_OVERLAY, + CAPTURE_OWNER_CONTROLLER, Ordering::AcqRel, Ordering::Acquire, ) - .map_err(|owner| { - anyhow::anyhow!(match owner { - CAPTURE_OWNER_AGENT => "Agent voice input already owns the microphone", - _ => "Another transcription capture is already starting", - }) - })?; - } else if CAPTURE_OWNER.load(Ordering::Acquire) == CAPTURE_OWNER_AGENT { - anyhow::bail!("Agent voice input already owns the microphone"); + .map_err(|_| anyhow::anyhow!("Another transcription capture is already starting"))?; } optimistically_show_overlay(&event).await; @@ -1072,7 +918,7 @@ async fn dispatch_recording_with_capture_gate( compensate_orphaned_preparing(&controller).await; if controller.current_state().await == State::Idle { let _ = CAPTURE_OWNER.compare_exchange( - CAPTURE_OWNER_OVERLAY, + CAPTURE_OWNER_CONTROLLER, CAPTURE_OWNER_NONE, Ordering::AcqRel, Ordering::Acquire, @@ -1241,19 +1087,16 @@ mod dispatch_tests { } } -/// The routing contract of [`route_hotkey_event`]: capture ownership is atomic -/// and mutually exclusive, assistive holds map to Agent start/stop, UI-only -/// commands never reach recording dispatch, and the assistive trigger context is -/// armed on start but never re-armed at send time. +/// The routing contract of [`route_hotkey_event`]: all capture modes reach one +/// recording callback; Agent UI notification carries no audio/transcript data. #[cfg(test)] mod app_action_tests { use super::*; use std::sync::atomic::AtomicUsize; - /// Test double that counts `on_show_agent` / `on_agent_capture` invocations. + /// Test double that counts UI-only Agent summons. struct CountingAppActionListener { show_agent_calls: AtomicUsize, - capture_calls: AtomicUsize, } impl CsAppActionListener for CountingAppActionListener { @@ -1261,85 +1104,38 @@ mod app_action_tests { fn on_show_agent(&self) { self.show_agent_calls.fetch_add(1, Ordering::SeqCst); } - - /// Count Agent capture commands (start/stop/toggle) without acting. - fn on_agent_capture(&self, _command: CsAgentCaptureCommand) { - self.capture_calls.fetch_add(1, Ordering::SeqCst); - } } - /// Agent claim blocks overlay claim; release restores NONE for the next owner. + /// Every session-starting gesture is recognized by the same capture gate. #[test] - #[serial_test::serial] - fn capture_owner_is_atomic_and_mutually_exclusive() { - CAPTURE_OWNER.store(CAPTURE_OWNER_NONE, Ordering::SeqCst); - assert!(claim_agent_capture()); - assert_eq!(CAPTURE_OWNER.load(Ordering::SeqCst), CAPTURE_OWNER_AGENT); - assert!( - CAPTURE_OWNER - .compare_exchange( - CAPTURE_OWNER_NONE, - CAPTURE_OWNER_OVERLAY, - Ordering::AcqRel, - Ordering::Acquire, - ) - .is_err() - ); - release_agent_capture(); - assert_eq!(CAPTURE_OWNER.load(Ordering::SeqCst), CAPTURE_OWNER_NONE); - } - - /// Chat/Selection hold Down→Start and Up→Stop; pure mapping, no ownership. - #[test] - fn assistive_hold_maps_to_agent_start_and_stop() { - assert_eq!( - agent_capture_command(&HotkeyEvent::Hold { + fn dictation_agent_and_assistive_all_start_shared_capture() { + for event in [ + HotkeyEvent::ToggleNormal, + HotkeyEvent::ToggleAssistive, + HotkeyEvent::Hold { action: HoldAction::Down, - mode: HoldMode::Chat, - }), - Some(CsAgentCaptureCommand::Start) - ); - assert_eq!( - agent_capture_command(&HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Selection, - }), - Some(CsAgentCaptureCommand::Stop) - ); - } - - /// Mid-recording assistive upgrade cannot migrate ownership; release stays raw. - #[test] - #[serial_test::serial] - fn overlay_owned_assistive_release_still_stops_the_overlay_owner() { - CAPTURE_OWNER.store(CAPTURE_OWNER_OVERLAY, Ordering::SeqCst); - assert_eq!( - overlay_owned_assistive_hold_fallback(&HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Chat, - }), - Some(HotkeyEvent::Hold { - action: HoldAction::Up, mode: HoldMode::Raw, - }) - ); - CAPTURE_OWNER.store(CAPTURE_OWNER_NONE, Ordering::SeqCst); + }, + HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Chat, + }, + ] { + assert!( + event_can_start_capture(&event), + "missing shared start for {event:?}" + ); + } } - /// ShowAgent is UI-only; recording/assistive arming/preparing stay untouched. + /// ShowAgent remains UI-only; recording gestures all reach the same callback. #[test] - #[serial_test::serial] - fn show_agent_routes_without_recording_or_preparing_payload() { - PREPARING_PENDING.store(false, Ordering::SeqCst); - CAPTURE_OWNER.store(CAPTURE_OWNER_NONE, Ordering::SeqCst); + fn agent_notification_does_not_own_capture_or_transcript_payload() { let listener = Arc::new(CountingAppActionListener { show_agent_calls: AtomicUsize::new(0), - capture_calls: AtomicUsize::new(0), }); let recording_calls = Arc::new(AtomicUsize::new(0)); - let arm_calls = Arc::new(AtomicUsize::new(0)); let recording_calls_for_route = Arc::clone(&recording_calls); - let arm_calls_for_route = Arc::clone(&arm_calls); route_hotkey_event( HotkeyEvent::ShowAgent, @@ -1348,19 +1144,12 @@ mod app_action_tests { recording_calls_for_route.fetch_add(1, Ordering::SeqCst); }, || panic!("show agent must not dispatch deferred insert"), - move || { - arm_calls_for_route.fetch_add(1, Ordering::SeqCst); - }, ); assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 1); - assert_eq!(listener.capture_calls.load(Ordering::SeqCst), 0); assert_eq!(recording_calls.load(Ordering::SeqCst), 0); - assert_eq!(arm_calls.load(Ordering::SeqCst), 0); - assert!(!PREPARING_PENDING.load(Ordering::SeqCst)); let recording_calls_for_route = Arc::clone(&recording_calls); - let arm_calls_for_route = Arc::clone(&arm_calls); route_hotkey_event( HotkeyEvent::ToggleNormal, Some(listener.clone()), @@ -1368,16 +1157,11 @@ mod app_action_tests { recording_calls_for_route.fetch_add(1, Ordering::SeqCst); }, || panic!("recording command must not dispatch deferred insert"), - move || { - arm_calls_for_route.fetch_add(1, Ordering::SeqCst); - }, ); assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 1); assert_eq!(recording_calls.load(Ordering::SeqCst), 1); - assert_eq!(arm_calls.load(Ordering::SeqCst), 0); let recording_calls_for_route = Arc::clone(&recording_calls); - let arm_calls_for_route = Arc::clone(&arm_calls); route_hotkey_event( HotkeyEvent::ToggleAssistive, Some(listener.clone()), @@ -1385,14 +1169,9 @@ mod app_action_tests { recording_calls_for_route.fetch_add(1, Ordering::SeqCst); }, || panic!("assistive command must not dispatch deferred insert"), - move || { - arm_calls_for_route.fetch_add(1, Ordering::SeqCst); - }, ); - assert_eq!(listener.capture_calls.load(Ordering::SeqCst), 1); - assert_eq!(recording_calls.load(Ordering::SeqCst), 1); - // Capture owner was NONE → a new agent capture starts → trigger armed. - assert_eq!(arm_calls.load(Ordering::SeqCst), 1); + assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 2); + assert_eq!(recording_calls.load(Ordering::SeqCst), 2); let deferred_calls = Arc::new(AtomicUsize::new(0)); let deferred_calls_for_route = Arc::clone(&deferred_calls); @@ -1403,35 +1182,9 @@ mod app_action_tests { move || { deferred_calls_for_route.fetch_add(1, Ordering::SeqCst); }, - || panic!("deferred insert must not arm assistive trigger"), ); assert_eq!(deferred_calls.load(Ordering::SeqCst), 1); - assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 1); - } - - /// Stop on an AGENT-owned capture must not re-arm trigger selection at send. - #[test] - #[serial_test::serial] - fn assistive_stop_does_not_recapture_at_send_time() { - // Owner already AGENT → this command stops an active capture; the - // contract forbids capturing selection at send time. - CAPTURE_OWNER.store(CAPTURE_OWNER_AGENT, Ordering::SeqCst); - let listener = Arc::new(CountingAppActionListener { - show_agent_calls: AtomicUsize::new(0), - capture_calls: AtomicUsize::new(0), - }); - route_hotkey_event( - HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Chat, - }, - Some(listener.clone()), - |_| panic!("assistive stop must not enter recording dispatch"), - || panic!("assistive stop must not dispatch deferred insert"), - || panic!("assistive stop must not re-arm the trigger context"), - ); - assert_eq!(listener.capture_calls.load(Ordering::SeqCst), 1); - CAPTURE_OWNER.store(CAPTURE_OWNER_NONE, Ordering::SeqCst); + assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 2); } } diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs index 31b4264f..489c8141 100644 --- a/bridge/src/lib.rs +++ b/bridge/src/lib.rs @@ -10,7 +10,7 @@ //! - `agent_status` — CodescribeAgentStatus (read-only readiness + MCP status) [W-C1] //! - `mcp_admin` — CodescribeMcpAdmin (add/update/remove/test MCP servers) [W-C4] //! - `config` — CodescribeConfig (settings/prompts/keychain/onboarding) [W3 #1] -//! - `recording` — CodescribeDictation + CsTranscriptionListener (STT) [W3 #3] +//! - `recording` — shared controller listener + audio/model settings [live] //! - `threads` — CodescribeThreads (thread persistence + history) [W3 #5] //! //! Shared cross-slice types (`CsError`, `CsLanguage`) live here so each submodule diff --git a/bridge/src/recording.rs b/bridge/src/recording.rs index 569bc58b..949e0775 100644 --- a/bridge/src/recording.rs +++ b/bridge/src/recording.rs @@ -1,30 +1,13 @@ -//! Dictation / STT surface — thin UniFFI wrapper over the live codescribe -//! streaming recorder + Whisper singleton. Translates the engine's semantic -//! `EngineEvent` stream into a small foreign listener contract so the new -//! SwiftUI app can drive real microphone dictation and file transcription. -//! Filled by W3 cut #3 (sibling to `agent.rs`). Uses shared -//! `crate::{CsError, CsLanguage}`. +//! Shared recording bridge types: audio-input settings, Whisper model download, +//! the controller event listener, and microphone permission probes. Live capture +//! itself is owned exclusively by `CodescribeHotkeys`/`RecordingController`. -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex as StdMutex, RwLock}; -use std::time::{Duration, Instant}; +use std::sync::Arc; -use codescribe::os::tray_status::{self, TrayStatus}; -use codescribe_core::asr_session::GatewaySessionAvailability; -use codescribe_core::audio::load_audio_file; -use codescribe_core::audio::streaming_recorder::StreamingRecorder; -use codescribe_core::config::{FinalPassRoutingMode, UserSettings}; -use codescribe_core::pipeline::contracts::{ - AnnotationKind, EngineEvent, EventSink, FileTranscriptionOptions, LayerSource, LayerSummary, - warning_is_user_terminal, -}; -use codescribe_core::stt::{TailGapBoundary, resolve_tail_gap_boundary, whisper}; +use codescribe_core::pipeline::contracts::{AnnotationKind, LayerSource, LayerSummary}; use cpal::traits::{DeviceTrait, HostTrait}; -use tokio::sync::Mutex; -use tracing::{info, warn}; -use crate::{CsError, CsLanguage}; +use crate::CsError; /// Result of a one-shot file transcription. #[derive(uniffi::Record)] @@ -382,691 +365,6 @@ pub trait CsTranscriptionListener: Send + Sync { fn on_error(&self, message: String); } -/// Accumulates finalized utterance text for the composer voice-note return, -/// mirroring core's crate-private `SessionTranscriptCollector` discipline -/// (skip empty, single-space join, trimmed). The same `CsEventSink` that -/// forwards engine events to Swift feeds each `UtteranceFinal` here, so -/// `stop_recording` can compose the return AFTER the streaming session's -/// completion signal fires — reusing existing finalization, not a new channel. -#[derive(Default)] -struct ComposerTranscript { - text: StdMutex, - utterances: AtomicU64, - /// End timestamp of the last committed utterance — the audio boundary Smart - /// mode gap-fills from. Mirrors `SessionTelemetrySink` in the controller lane. - committed_through_secs: StdMutex>, -} - -impl ComposerTranscript { - /// Advance the committed audio boundary (monotonic max: an out-of-order - /// final never rewinds it). Called for **every** `UtteranceFinal`, including - /// empty ones — that audio is adjudicated even when it carried no text, so - /// a tail gap-fill must not transcribe it again. - fn note_committed_through(&self, end_ts: f32) { - if !end_ts.is_finite() { - return; - } - let mut guard = self - .committed_through_secs - .lock() - .unwrap_or_else(|e| e.into_inner()); - *guard = Some(match *guard { - Some(current) if current >= end_ts => current, - _ => end_ts, - }); - } - - /// Committed audio boundary, or `None` when no final sealed any audio yet. - fn committed_through_secs(&self) -> Option { - *self - .committed_through_secs - .lock() - .unwrap_or_else(|e| e.into_inner()) - } - - /// Append one finalized utterance (Layer 0 committed text). Empty/whitespace - /// finals are ignored so trailing silence never widens the transcript. - fn append_final(&self, text: &str) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - let mut buf = self.text.lock().unwrap_or_else(|e| e.into_inner()); - if !buf.is_empty() { - buf.push(' '); - } - buf.push_str(trimmed); - self.utterances.fetch_add(1, Ordering::Relaxed); - } - - /// Current composed transcript and the number of utterances that fed it. - fn snapshot(&self) -> (String, u64) { - let text = self.text.lock().unwrap_or_else(|e| e.into_inner()).clone(); - (text, self.utterances.load(Ordering::Relaxed)) - } -} - -/// Wait budget for `stop_recording` to compose its return: it covers BOTH the -/// streaming drain AND the delivery-grade final pass over the saved WAV. -/// Proportional to recording length (STT work scales with audio) but clamped so -/// the composer UI never hangs indefinitely if the scheduler stalls (e.g. -/// thermal throttling): the floor covers a cold commit + short final pass, the -/// cap bounds the worst case. On exhaustion the streaming splice is returned as -/// a fallback, so overrun degrades quality, never correctness. -fn compose_stop_timeout(elapsed: Duration) -> Duration { - /// Minimum drain budget so a cold commit + short final pass still fits. - const FLOOR: Duration = Duration::from_secs(8); - /// Hard upper bound so a stalled scheduler never hangs the composer forever. - const CAP: Duration = Duration::from_secs(30); - elapsed.mul_f32(0.6).clamp(FLOOR, CAP) -} - -/// Which transcript `stop_recording` returned, for the stop breadcrumb. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ComposerTranscriptSource { - /// Live floor + whole-WAV Whisper gap-fill (doctrine: never full-replace). - /// Also covers whisper-only when the stream produced nothing. - MergedLiveWhisper, - /// Smart tail gap-fill APPENDED to the committed streaming floor. The tail is - /// a bare fragment, never diffed against committed text (append-only doctrine). - TailGapAppend, - /// Spliced streaming `UtteranceFinal` chunks (final pass unavailable/empty). - StreamingFallback, -} - -impl ComposerTranscriptSource { - /// Stable log token for the stop breadcrumb. - fn label(self) -> &'static str { - match self { - Self::MergedLiveWhisper => "merged_live_whisper", - Self::TailGapAppend => "tail_gap_append", - Self::StreamingFallback => "streaming_fallback", - } - } -} - -/// Pick the composer return. -/// -/// Overlay doctrine (AGENTS.md law): the live streaming assembly is the floor -/// of truth — a non-empty whole-WAV final pass never replaces it, it merges as -/// gap-fill via `merge_live_whisper` (substitution disagreements keep live, so -/// a collapsing file-STT final can no longer blank or shrink a real stream, -/// and an inflated stream is never swapped for a shorter Whisper guess). -/// Empty/absent final falls back to the streaming splice. Both inputs trimmed. -fn select_composer_transcript( - final_pass: Option<&str>, - streaming: &str, -) -> (String, ComposerTranscriptSource) { - let streaming = streaming.trim(); - if let Some(text) = final_pass { - let trimmed = text.trim(); - if !trimmed.is_empty() { - let merged = codescribe_core::quality::merge_live_whisper(streaming, trimmed); - return (merged.text, ComposerTranscriptSource::MergedLiveWhisper); - } - } - ( - streaming.to_string(), - ComposerTranscriptSource::StreamingFallback, - ) -} - -/// Compose the composer return from the planned final pass — the plan decides -/// HOW the Whisper text is allowed to meet the live floor. -/// -/// - `TailGap` (Smart): the Whisper text is a **bare tail** of the uncommitted -/// audio, not a transcript of the whole session. It is APPENDED via the shared -/// core primitive (`codescribe_core::stt::append_tail_gap`), which keeps the -/// committed text as an untouched prefix and only dedups repeated preview words -/// from the tail side. Feeding a bare tail to `merge_live_whisper` (as this lane -/// used to) turns the boundary Delete+Insert pair into a Substitute that keeps -/// live and DISCARDS the whisper token — measurable gap-fill word loss. -/// - `FullFile` (Always): a whole-WAV transcript, which is exactly what -/// `merge_live_whisper` is built for — merge as gap-fill over the live floor. -/// - `SkipStreaming` (Off / Smart-without-boundary): no final pass exists; -/// the streaming splice is the answer. -fn compose_composer_transcript( - plan: ComposerFinalPassPlan, - final_pass: Option<&str>, - streaming: &str, -) -> (String, ComposerTranscriptSource) { - match plan { - ComposerFinalPassPlan::TailGap(_) => { - let streaming = streaming.trim(); - let tail = final_pass.map(str::trim).unwrap_or_default(); - if tail.is_empty() { - return ( - streaming.to_string(), - ComposerTranscriptSource::StreamingFallback, - ); - } - ( - codescribe_core::stt::append_tail_gap(streaming, tail), - ComposerTranscriptSource::TailGapAppend, - ) - } - ComposerFinalPassPlan::FullFile | ComposerFinalPassPlan::SkipStreaming => { - select_composer_transcript(final_pass, streaming) - } - } -} - -/// What the composer stop lane is allowed to run over the saved WAV, per -/// `FINAL_PASS_MODE` (operator law 2026-08-05). -#[derive(Debug, Clone, Copy, PartialEq)] -enum ComposerFinalPassPlan { - /// Always only: re-transcribe the whole file. - FullFile, - /// Smart: transcribe the uncommitted tail from this boundary and append it. - TailGap(f32), - /// Off — or Smart without usable commit evidence: no Whisper at all; the - /// streaming splice is the answer. - SkipStreaming, -} - -impl ComposerFinalPassPlan { - /// Stable log token for the chosen plan (the `TailGap` boundary is logged - /// separately, so variants with payloads still map to one flat name). - fn label(self) -> &'static str { - match self { - Self::FullFile => "full_file", - Self::TailGap(_) => "tail_gap", - Self::SkipStreaming => "skip_streaming", - } - } -} - -/// Route the composer stop lane by mode — the same law the controller lane obeys. -/// -/// Always is the ONLY mode permitted a full-file re-pass; Off runs zero Whisper -/// on the stop path; Smart delegates its boundary question to the shared core -/// guard so a missing boundary can never degrade into a whole-file pass landing -/// on committed text. -fn composer_final_pass_plan( - mode: FinalPassRoutingMode, - committed_through_secs: Option, - streaming_is_empty: bool, -) -> ComposerFinalPassPlan { - match mode { - FinalPassRoutingMode::Always => ComposerFinalPassPlan::FullFile, - FinalPassRoutingMode::Off => ComposerFinalPassPlan::SkipStreaming, - FinalPassRoutingMode::Smart => { - match resolve_tail_gap_boundary(committed_through_secs, streaming_is_empty) { - TailGapBoundary::From(secs) => ComposerFinalPassPlan::TailGap(secs), - TailGapBoundary::WholeSessionBootstrap => ComposerFinalPassPlan::TailGap(0.0), - TailGapBoundary::Skip => ComposerFinalPassPlan::SkipStreaming, - } - } - } -} - -/// Run the planned final pass over the saved WAV. -/// -/// `FullFile` mirrors the controller's toggle-stop adjudicator -/// (`transcribe_file_verdict` with default options); `TailGap` transcribes only -/// the uncommitted tail (append-only doctrine); `SkipStreaming` never touches -/// Whisper. Blocking work runs off the async runtime and is bounded by the -/// shared `deadline`; any failure/timeout/absent-WAV/empty text yields `None` -/// so the caller falls back to the streaming splice. -async fn run_final_pass( - plan: ComposerFinalPassPlan, - audio_path: Option, - language: Option, - deadline: tokio::time::Instant, -) -> Option { - if matches!(plan, ComposerFinalPassPlan::SkipStreaming) { - return None; - } - let path = audio_path?; - let job = tokio::task::spawn_blocking(move || match plan { - ComposerFinalPassPlan::TailGap(from_secs) => { - codescribe_core::stt::whisper_tail_gap_transcribe_file( - &path, - from_secs, - language.as_deref(), - ) - .map(|raw| raw.text) - } - // Always — the ONLY mode permitted a whole-file re-pass. - ComposerFinalPassPlan::FullFile => whisper::transcribe_file_verdict( - &path, - language.as_deref(), - FileTranscriptionOptions::default(), - ) - .map(|verdict| verdict.text), - // Returned above; kept explicit so a FUTURE plan variant is a compile - // error here instead of silently routing into the full-file re-pass. - ComposerFinalPassPlan::SkipStreaming => Ok(String::new()), - }); - match tokio::time::timeout_at(deadline, job).await { - Ok(Ok(Ok(text))) if !text.trim().is_empty() => Some(text), - Ok(Ok(Ok(_))) => None, - Ok(Ok(Err(e))) => { - warn!(target: "composer-dictation", error = %e, "final pass transcription failed"); - None - } - Ok(Err(e)) => { - warn!(target: "composer-dictation", error = %e, "final pass task join failed"); - None - } - Err(_elapsed) => { - warn!(target: "composer-dictation", "final pass timed out; using streaming fallback"); - None - } - } -} - -/// Internal `EventSink` adapter (NOT exposed across FFI). Lives between the -/// core streaming pipeline and the foreign `CsTranscriptionListener`, -/// translating every `EngineEvent` variant into the appropriate listener call. -struct CsEventSink { - listener: Arc, - /// Composer-side accumulator: `stop_recording` reads its snapshot for the - /// return value (the Swift `on_final` callback is a no-op on this path). - transcript: Arc, -} - -impl EventSink for CsEventSink { - /// Translate one core `EngineEvent` into the foreign listener contract and - /// accumulate finals for the composer return path. - fn on_event(&self, event: &EngineEvent) { - match event { - EngineEvent::VadStart { .. } => self.listener.on_vad_active(true), - EngineEvent::VadEnd { .. } => self.listener.on_vad_active(false), - EngineEvent::NoSpeech { reason } => self.listener.on_no_speech(reason.clone()), - EngineEvent::Preview { text, .. } => self.listener.on_preview(text.clone()), - EngineEvent::Correction { - text, - previous_text, - .. - } => self - .listener - .on_correction(text.clone(), previous_text.clone()), - EngineEvent::UtteranceFinal { - utterance_id, - text, - end_ts, - avg_logprob, - vad_speech_pct, - confidence_flags, - .. - } => { - // Compose the composer return here: the streaming recorder's own - // transcript buffer is never filled on this path. - self.transcript.append_final(text); - self.transcript.note_committed_through(*end_ts); - let flags: Vec = confidence_flags.iter().map(ToString::to_string).collect(); - self.listener.on_final( - *utterance_id, - text.clone(), - *avg_logprob, - *vad_speech_pct, - flags, - ); - } - EngineEvent::ReplaceRange { - utterance_id, - start, - end, - text, - source, - } => self.listener.on_replace_range( - *utterance_id, - *start as u64, - *end as u64, - text.clone(), - (*source).into(), - ), - EngineEvent::InsertAnnotation { - utterance_id, - position, - text, - kind, - } => self.listener.on_insert_annotation( - *utterance_id, - *position as u64, - text.clone(), - kind.into(), - ), - EngineEvent::SessionFinalised { - session_id, - layer_summary, - } => self - .listener - .on_session_finalised(session_id.clone(), layer_summary.into()), - // Warnings split by class (`warning_is_user_terminal`): a real - // failure surfaces on `on_error`; a quality receipt is log-only. - // Receipts must never ride the error channel — the composer treats - // `on_error` during capture as terminal, and a routine overlap - // receipt painted "Dictation stopped" over a live session, desynced - // the toggle parity and left an orphaned capture holding the mic - // behind an Idle tray (2026-08-12). The tray stays untouched either - // way: `TrayStatus::Error` means "backend not available". - EngineEvent::Warning { code, message } => { - if warning_is_user_terminal(code) { - self.listener.on_error(format!("{code}: {message}")); - } else { - info!(code, message, "engine warning (receipt, not forwarded)"); - } - } - // Engine-internal bookkeeping (dropped content, session stats) has no - // listener surface; intentionally ignored. - EngineEvent::Drop { .. } | EngineEvent::Stats { .. } => {} - } - } -} - -/// Resolve the Whisper language hint for a manual voice-note session. -/// -/// An explicit caller choice wins; `None` falls back to the persisted -/// `WHISPER_LANGUAGE` setting (mirroring the hotkey path in -/// `RecordingController`) rather than forcing blind auto-detect — the latter -/// mis-guessed `en`/`ru` on short manual notes. `Auto` collapses to `None` -/// (genuine auto-detect) via `whisper_hint`, never the literal `"auto"` code. -/// Uses `load_without_keychain` so opening the composer mic never triggers a -/// Keychain prompt. -fn resolve_language_hint(language: Option) -> Option { - match language { - Some(lang) => codescribe_core::config::Language::from(lang).whisper_hint(), - None => codescribe_core::config::Config::load_without_keychain() - .whisper_language - .whisper_hint(), - } - .map(str::to_string) -} - -/// One live composer voice-note session: the streaming recorder plus the -/// finalized-text accumulator its event sink feeds, the wall-clock start used to -/// size the stop timeout, and the resolved Whisper language hint reused for the -/// stop-time final pass (kept so it honours the persisted setting exactly like -/// the start-time streaming session). -struct ActiveSession { - recorder: StreamingRecorder, - transcript: Arc, - started_at: Instant, - language_hint: Option, -} - -/// Thin handle to the codescribe dictation engine (streaming recorder + -/// Whisper). Holds the active session behind an async mutex and the current -/// foreign listener behind an `RwLock`. -#[derive(uniffi::Object)] -pub struct CodescribeDictation { - recorder: Mutex>, - listener: RwLock>>, -} - -#[uniffi::export(async_runtime = "tokio")] -impl CodescribeDictation { - /// Build an idle dictation handle and initialize logging. No microphone or - /// model work happens here — call `set_listener` then `start_recording`. - #[uniffi::constructor] - pub fn new() -> Self { - codescribe::logging::init_logging(); - Self { - recorder: Mutex::new(None), - listener: RwLock::new(None), - } - } - - /// Register (or replace) the foreign listener that receives dictation - /// events. Must be called before `start_recording`. - pub fn set_listener(&self, listener: Arc) { - if let Ok(mut guard) = self.listener.write() { - *guard = Some(listener); - } - } - - /// Optionally warm Whisper weights. Runs on a blocking thread because model - /// load touches the GPU and can take seconds. - /// - /// When the live engine is Apple, Whisper is **gap-fill only** (file final / - /// emergency recovery). Missing weights must never refuse recording start — - /// we log an honest degraded-mode note and return `Ok(())`. Candle-live - /// still requires a model and surfaces load errors. - /// Wraps `whisper::init` (stt/whisper/singleton.rs). - pub async fn init_model(&self) -> Result<(), CsError> { - let apple_live = codescribe::stt::active_engine_is_apple(); - let result = tokio::task::spawn_blocking(whisper::init) - .await - .map_err(|e| CsError::Recording { - msg: format!("init_model task join error: {e}"), - })?; - match result { - Ok(()) => Ok(()), - Err(e) if apple_live => { - tracing::warn!("no Whisper gap fill this session (Apple live continues): {e:#}"); - Ok(()) - } - Err(e) => Err(CsError::Recording { msg: e.to_string() }), - } - } - - /// True when the Whisper engine is currently loaded. May flip back to - /// `false` after idle-unload; the next transcription reloads transparently. - /// Wraps `whisper::is_initialized` (stt/whisper/singleton.rs:207). - pub fn is_model_loaded(&self) -> bool { - whisper::is_initialized() - } - - /// Whether the default Whisper weights are on disk / embedded (not necessarily loaded). - pub fn whisper_model_ready_status(&self) -> CsWhisperModelStatus { - CsWhisperModelStatus::from(codescribe_core::config::models::whisper_model_status()) - } - - /// Start microphone dictation. Builds a `CsEventSink` from the registered - /// listener, wires it into a fresh `StreamingRecorder`, and starts the - /// event-based transcription session. - /// - /// Wraps `StreamingRecorder::new` (audio/streaming_recorder.rs:25), - /// `set_event_sink` (:74) and `start_event_session` (:87). Errors if no - /// listener was set (the core pipeline requires an event sink). - pub async fn start_recording(&self, language: Option) -> Result<(), CsError> { - let listener = self - .listener - .read() - .map_err(|_| CsError::Recording { - msg: "listener lock poisoned".to_string(), - })? - .clone() - .ok_or_else(|| CsError::Recording { - msg: "set_listener(...) must be called before start_recording".to_string(), - })?; - - let transcript = Arc::new(ComposerTranscript::default()); - let sink: Arc = Arc::new(CsEventSink { - listener: Arc::clone(&listener), - transcript: Arc::clone(&transcript), - }); - let mut recorder = - StreamingRecorder::new().map_err(|e| CsError::Recording { msg: e.to_string() })?; - recorder.set_event_sink(Some(sink)); - recorder.configure_layer1( - &UserSettings::load(), - GatewaySessionAvailability::Unavailable, - ); - - // Manual voice-note: the composer's Stop click is the source of truth, - // exactly like the hotkey hold's key-up (see `RecordingController` - // hold-start, which also sets `auto_silence = false`). The legacy - // `RecorderConfig` defaults to `auto_silence = true`, which auto-stops the - // stream after ~0.3s of silence and chops a single spoken note into - // fragments the commit-VAD then rejects as "no speech". Disable it so the - // user — not the VAD — ends the recording. - recorder.recorder.config.auto_silence = false; - - let language_code = resolve_language_hint(language); - recorder - .start_event_session(language_code.clone()) - .await - .map_err(|e| CsError::Recording { msg: e.to_string() })?; - - *self.recorder.lock().await = Some(ActiveSession { - recorder, - transcript, - started_at: Instant::now(), - language_hint: language_code, - }); - tray_status::update_tray_status(TrayStatus::Listening); - listener.on_recording_started(); - Ok(()) - } - - /// Stop the active dictation session and return the composed transcript. - /// - /// Two-phase, within one shared budget (`compose_stop_timeout`): - /// - /// 1. `StreamingRecorder::stop` is the completion signal — it stops the - /// audio stream, joins the transcription task (which only finishes AFTER - /// every `UtteranceFinal` has been emitted synchronously into our - /// accumulator), and saves the WAV. So the streaming splice is complete - /// once stop returns cleanly. - /// 2. The final pass `FINAL_PASS_MODE` permits (`composer_final_pass_plan`, - /// same law as the controller lane): **Always** re-transcribes the whole - /// saved WAV with the `transcribe_file_verdict` adjudicator the - /// hotkey/overlay toggle-stop uses; **Smart** transcribes only the audio - /// after the last committed utterance and merges it as gap-fill; - /// **Off** runs no Whisper at all and streaming is final. - /// - /// The final pass wins whenever it yields non-empty text; the streaming - /// splice is the fallback for a failed/timed-out/empty final pass (or a - /// drain timeout, where no WAV is composed). Either way the UI never hangs: - /// the shared budget bounds both phases and overrun degrades quality, not - /// correctness. The streaming recorder's own transcript buffer is ignored — - /// it stays empty on this path. - pub async fn stop_recording(&self) -> Result { - let mut session = { - let mut guard = self.recorder.lock().await; - guard.take().ok_or_else(|| CsError::Recording { - msg: "no active recording to stop".to_string(), - })? - }; - - let budget = compose_stop_timeout(session.started_at.elapsed()); - let deadline = tokio::time::Instant::now() + budget; - let transcript = Arc::clone(&session.transcript); - let language_hint = session.language_hint.clone(); - self.notify_recording_finalising(); - - // Phase 1: drain the streaming session and recover the saved WAV path. - let audio_path = match tokio::time::timeout_at(deadline, session.recorder.stop()).await { - Ok(Ok((_streaming_buf, audio_path))) => audio_path, - Ok(Err(e)) => { - tray_status::update_tray_status(TrayStatus::Error); - return Err(CsError::Recording { msg: e.to_string() }); - } - Err(_elapsed) => { - // Drain overran the budget — no WAV to adjudicate; return the - // streaming finals accumulated so far. - let (streaming_text, utterances) = transcript.snapshot(); - let text = streaming_text.trim().to_string(); - warn!( - target: "composer-dictation", - source = ComposerTranscriptSource::StreamingFallback.label(), - utterances, - streaming_chars = text.chars().count(), - budget_ms = budget.as_millis() as u64, - "composer voice-note stop drain timed out; returning streaming fallback" - ); - self.notify_recording_stopped(); - return Ok(text); - } - }; - - // Phase 2: the final pass `FINAL_PASS_MODE` permits — full file under - // Always, uncommitted-tail gap-fill under Smart, nothing under Off. The - // streaming splice remains the fallback authority in every mode. - let (streaming_text, _utterances) = transcript.snapshot(); - let mode = codescribe_core::config::final_pass_routing_mode(); - let plan = composer_final_pass_plan( - mode, - transcript.committed_through_secs(), - streaming_text.trim().is_empty(), - ); - info!( - target: "composer-dictation", - mode = mode.as_str(), - plan = plan.label(), - committed_through_secs = transcript.committed_through_secs(), - "composer voice-note stop final-pass plan" - ); - let final_pass_text = run_final_pass(plan, audio_path, language_hint, deadline).await; - - let final_pass_chars = final_pass_text - .as_deref() - .map(|t| t.trim().chars().count()) - .unwrap_or(0); - let (text, source) = - compose_composer_transcript(plan, final_pass_text.as_deref(), &streaming_text); - - info!( - target: "composer-dictation", - source = source.label(), - plan = plan.label(), - final_pass_chars, - streaming_chars = streaming_text.trim().chars().count(), - "composer voice-note stop composed transcript" - ); - - self.notify_recording_stopped(); - Ok(text) - } - - /// Fire the foreign `on_recording_stopped` callback if a listener is set. - fn notify_recording_stopped(&self) { - tray_status::update_tray_status(TrayStatus::Idle); - if let Ok(guard) = self.listener.read() - && let Some(listener) = guard.as_ref() - { - listener.on_recording_stopped(); - } - } - - /// Fire the foreign `on_recording_finalising` callback and publish processing. - fn notify_recording_finalising(&self) { - tray_status::update_tray_status(TrayStatus::Thinking); - if let Ok(guard) = self.listener.read() - && let Some(listener) = guard.as_ref() - { - listener.on_recording_finalising(); - } - } - - /// True while a dictation session is active. - /// Wraps `StreamingRecorder::is_recording` (audio/streaming_recorder.rs:79). - pub async fn is_recording(&self) -> bool { - self.recorder - .lock() - .await - .as_ref() - .map(|session| session.recorder.is_recording()) - .unwrap_or(false) - } - - /// Transcribe an existing audio file. Loads + decodes the file, detects the - /// language, then runs Whisper. All blocking work runs off the async runtime. - /// - /// Wraps `audio::load_audio_file` (audio/loader.rs:10), - /// `whisper::detect_language` (stt/whisper/singleton.rs:249) and - /// `whisper::transcribe` (stt/whisper/singleton.rs:214). - pub async fn transcribe_file(&self, path: String) -> Result { - tokio::task::spawn_blocking(move || -> Result { - let path = std::path::PathBuf::from(path); - let (samples, sample_rate) = - load_audio_file(&path).map_err(|e| CsError::Recording { msg: e.to_string() })?; - let language = whisper::detect_language(&samples, sample_rate) - .map_err(|e| CsError::Recording { msg: e.to_string() })?; - let text = whisper::transcribe(&samples, sample_rate, Some(language.as_str())) - .map_err(|e| CsError::Recording { msg: e.to_string() })?; - Ok(CsTranscription { text, language }) - }) - .await - .map_err(|e| CsError::Recording { - msg: format!("transcribe_file task join error: {e}"), - })? - } -} - /// True when microphone permission is already granted. /// Wraps `os::permissions::check_microphone` (app/os/permissions.rs:135). #[uniffi::export] @@ -1083,490 +381,29 @@ pub fn request_mic_permission() -> bool { codescribe::os::permissions::request_microphone() } -/// Dictation-bridge unit coverage: audio-input resolution, event-sink identity -/// flow, composer commit boundaries, and final-pass plan mode truth. #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex as StdMutex; - /// Configured match, unavailable fallback, and default-only paths stay honest. #[test] fn audio_input_resolution_reports_live_match_and_unavailable_fallback() { let devices = vec![ "MacBook Pro Microphone".to_string(), "USB Studio Mic".to_string(), ]; - assert_eq!( - resolve_audio_input_state(Some("Studio Mic"), &devices, Some("MacBook Pro Microphone"),), + resolve_audio_input_state(Some("Studio Mic"), &devices, Some("MacBook Pro Microphone")), (Some("USB Studio Mic".to_string()), true, false) ); assert_eq!( resolve_audio_input_state( Some("Unplugged Mic"), &devices, - Some("MacBook Pro Microphone"), + Some("MacBook Pro Microphone") ), (Some("MacBook Pro Microphone".to_string()), false, true) ); - assert_eq!( - resolve_audio_input_state(None, &devices, Some("MacBook Pro Microphone")), - (Some("MacBook Pro Microphone".to_string()), true, false) - ); assert!(device_is_available(Some("Studio Mic"), &devices)); assert!(!device_is_available(Some("Unplugged Mic"), &devices)); } - - /// Captures the payload of the single listener call we assert on. - #[derive(Default)] - struct CapturingListener { - final_calls: StdMutex>, - error_calls: StdMutex>, - } - - impl CsTranscriptionListener for CapturingListener { - /// Lifecycle prepare — unused by sink identity tests. - fn on_recording_preparing(&self) {} - /// Lifecycle start — unused by sink identity tests. - fn on_recording_started(&self) {} - /// Lifecycle stop — unused by sink identity tests. - fn on_recording_stopped(&self) {} - /// Lifecycle finalising — unused by sink identity tests. - fn on_recording_finalising(&self) {} - /// Interim preview text — unused by sink identity tests. - fn on_preview(&self, _text: String) {} - /// Live correction text — unused by sink identity tests. - fn on_correction(&self, _text: String, _previous_text: String) {} - /// Record each final so tests can assert utterance_id + text together. - fn on_final( - &self, - utterance_id: u64, - text: String, - _avg_logprob: Option, - _speech_pct: Option, - _confidence_flags: Vec, - ) { - self.final_calls.lock().unwrap().push((utterance_id, text)); - } - /// Bounded replace events — unused by the capture fixture. - fn on_replace_range( - &self, - _utterance_id: u64, - _start: u64, - _end: u64, - _text: String, - _source: CsLayerSource, - ) { - } - /// Inline annotations — unused by the capture fixture. - fn on_insert_annotation( - &self, - _utterance_id: u64, - _position: u64, - _text: String, - _kind: CsAnnotationKind, - ) { - } - /// Context markers — unused by the capture fixture. - fn on_context_marker(&self, _position: u64, _marker: String) {} - /// Session-end summary — unused by the capture fixture. - fn on_session_finalised(&self, _session_id: String, _layer_summary: CsLayerSummary) {} - /// Delivery-grade final transcript — unused by the capture fixture. - fn on_final_transcript_ready(&self, _text: String) {} - /// VAD active flips — unused by the capture fixture. - fn on_vad_active(&self, _active: bool) {} - /// RMS level ticks — unused by the capture fixture. - fn on_audio_level(&self, _rms: f32) {} - /// No-speech notices — unused by the capture fixture. - fn on_no_speech(&self, _reason: String) {} - /// Record error-channel deliveries so tests can assert the class split. - fn on_error(&self, message: String) { - self.error_calls.lock().unwrap().push(message); - } - } - - /// Build a minimal `UtteranceFinal` event with the given identity/text. - fn utterance_final(utterance_id: u64, text: &str) -> EngineEvent { - EngineEvent::UtteranceFinal { - utterance_id, - text: text.to_string(), - raw_text: text.to_string(), - start_ts: 0.0, - end_ts: 1.0, - segments: Vec::new(), - vad_speech_pct: None, - avg_logprob: None, - compression_ratio: None, - quality_gate_dropped: false, - confidence_flags: Vec::new(), - } - } - - /// Warnings split by class at the bridge: a quality receipt (engine kept - /// going) must never reach `on_error` — the composer treats that channel as - /// terminal, and a routine overlap receipt shown as "Dictation stopped" - /// desynced the toggle and left an orphaned capture holding the microphone - /// (2026-08-12). A real failure (`transcription_failed`) must still land, - /// or failures go silent again. - #[test] - fn warning_receipts_stay_off_the_error_channel_but_failures_land() { - let listener = Arc::new(CapturingListener::default()); - let sink = CsEventSink { - listener: listener.clone(), - transcript: Arc::new(ComposerTranscript::default()), - }; - - sink.on_event(&EngineEvent::Warning { - code: "apple_final_window_overlap_normalized".to_string(), - message: "Apple final overlap removed at segment boundary".to_string(), - }); - assert!( - listener.error_calls.lock().unwrap().is_empty(), - "a quality receipt must never ride the error channel" - ); - - sink.on_event(&EngineEvent::Warning { - code: "transcription_failed".to_string(), - message: "boom".to_string(), - }); - assert_eq!( - listener.error_calls.lock().unwrap().as_slice(), - &["transcription_failed: boom".to_string()], - "a user-terminal failure must still surface on on_error" - ); - } - - /// The bridge must forward `utterance_id` on `UtteranceFinal` so committed - /// sinks can stamp segment identity that later `ReplaceRange` patches target. - /// Regression guard for the W3 keystone (identity flow into committed text). - #[test] - fn utterance_final_forwards_utterance_id() { - let listener = Arc::new(CapturingListener::default()); - let sink = CsEventSink { - listener: listener.clone(), - transcript: Arc::new(ComposerTranscript::default()), - }; - - sink.on_event(&utterance_final(7, "ala ma kota")); - - let calls = listener.final_calls.lock().unwrap(); - assert_eq!( - calls.as_slice(), - &[(7, "ala ma kota".to_string())], - "on_final must receive the utterance_id from UtteranceFinal" - ); - } - - /// The composer return is composed from the finalized utterance stream: the - /// sink must accumulate each `UtteranceFinal` (space-joined, empties skipped) - /// so `stop_recording` never returns an empty transcript after real speech. - /// Regression guard for the "audio + STT work but final is empty" bug. - #[test] - fn cs_event_sink_accumulates_final_transcript() { - let listener = Arc::new(CapturingListener::default()); - let transcript = Arc::new(ComposerTranscript::default()); - let sink = CsEventSink { - listener: listener.clone(), - transcript: Arc::clone(&transcript), - }; - - sink.on_event(&utterance_final(1, " no to ")); - sink.on_event(&utterance_final(2, "")); // empty final must not widen text - sink.on_event(&utterance_final(3, "dobra teraz")); - - let (text, utterances) = transcript.snapshot(); - assert_eq!(text, "no to dobra teraz"); - assert_eq!( - utterances, 2, - "empty final must not count toward utterances" - ); - } - - /// Same as [`utterance_final`] but with an explicit commit boundary. - fn utterance_final_at(utterance_id: u64, text: &str, end_ts: f32) -> EngineEvent { - match utterance_final(utterance_id, text) { - EngineEvent::UtteranceFinal { - utterance_id, - text, - raw_text, - start_ts, - segments, - vad_speech_pct, - avg_logprob, - compression_ratio, - quality_gate_dropped, - confidence_flags, - .. - } => EngineEvent::UtteranceFinal { - utterance_id, - text, - raw_text, - start_ts, - end_ts, - segments, - vad_speech_pct, - avg_logprob, - compression_ratio, - quality_gate_dropped, - confidence_flags, - }, - other => other, - } - } - - /// Smart mode needs the composer lane's committed audio boundary, exactly as - /// the controller's `SessionTelemetrySink` tracks it: a monotonic max fold of - /// `UtteranceFinal::end_ts` that an out-of-order final can never rewind, and - /// that an empty final still advances (the audio IS adjudicated, it simply - /// carried no text — so it must not be gap-filled again). - #[test] - fn composer_transcript_tracks_committed_through_secs() { - let transcript = ComposerTranscript::default(); - assert_eq!( - transcript.committed_through_secs(), - None, - "no finals yet ⇒ no commit evidence" - ); - - let listener = Arc::new(CapturingListener::default()); - let sink = CsEventSink { - listener, - transcript: Arc::new(ComposerTranscript::default()), - }; - sink.on_event(&utterance_final_at(1, "raz", 2.5)); - sink.on_event(&utterance_final_at(2, "dwa", 7.25)); - // Out-of-order final: the boundary must not rewind. - sink.on_event(&utterance_final_at(3, "trzy", 4.0)); - // Empty final still seals its audio. - sink.on_event(&utterance_final_at(4, " ", 9.5)); - - assert_eq!(sink.transcript.committed_through_secs(), Some(9.5)); - assert_eq!( - sink.transcript.snapshot().0, - "raz dwa trzy", - "boundary tracking must not disturb the text accumulator" - ); - } - - /// The composer stop lane must obey `FINAL_PASS_MODE` exactly like the - /// controller lane: Always is the ONLY mode allowed a full-file re-pass, - /// Smart may only gap-fill the uncommitted tail, Off runs no Whisper at all. - #[test] - fn composer_final_pass_plan_honours_mode() { - // Always: full file, regardless of commit evidence or canvas state. - for (committed, empty) in [(None, false), (Some(4.0), false), (None, true)] { - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Always, committed, empty), - ComposerFinalPassPlan::FullFile, - "Always must full-file re-pass (committed={committed:?}, empty={empty})" - ); - } - - // Off: zero Whisper on the stop path, streaming is final. - for (committed, empty) in [(None, false), (Some(4.0), false), (None, true)] { - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Off, committed, empty), - ComposerFinalPassPlan::SkipStreaming, - "Off must never invoke Whisper (committed={committed:?}, empty={empty})" - ); - } - - // Smart with a committed boundary: gap-fill the tail only. - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, Some(6.5), false), - ComposerFinalPassPlan::TailGap(6.5) - ); - // Smart, no commit evidence, empty canvas: whole session is still an append. - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, None, true), - ComposerFinalPassPlan::TailGap(0.0) - ); - // Smart, no commit evidence, non-empty canvas: a whole-file pass would land - // on committed text — honest skip instead. - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, None, false), - ComposerFinalPassPlan::SkipStreaming - ); - // Non-finite / non-positive boundaries carry no evidence either. - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, Some(f32::NAN), false), - ComposerFinalPassPlan::SkipStreaming - ); - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, Some(0.0), false), - ComposerFinalPassPlan::SkipStreaming - ); - } - - /// The stop-drain budget scales with recording length but is clamped so the - /// composer UI can never hang indefinitely on a stalled scheduler. - #[test] - fn compose_stop_timeout_scales_and_clamps() { - /// Allow one-micro drift from floating proportional clamp arithmetic. - fn assert_duration_close(actual: Duration, expected: Duration) { - let drift = actual.abs_diff(expected); - assert!( - drift <= Duration::from_micros(1), - "duration drift {drift:?} exceeded tolerance: actual={actual:?}, expected={expected:?}" - ); - } - - // Short note: floored so a cold commit + tail patch still fits. - assert_eq!( - compose_stop_timeout(Duration::from_secs(3)), - Duration::from_secs(8) - ); - // Mid-length: proportional (20s * 0.6 = 12s) inside the band. - assert_duration_close( - compose_stop_timeout(Duration::from_secs(20)), - Duration::from_secs(12), - ); - // Long note: capped so the UI never waits unboundedly. - assert_eq!( - compose_stop_timeout(Duration::from_secs(300)), - Duration::from_secs(30) - ); - } - - /// Whisper excess fills live gaps (InsertB), never replaces the live floor. - #[test] - fn select_composer_transcript_merges_whisper_gap_fill() { - let (text, source) = select_composer_transcript(Some(" raz dwa trzy cztery "), "raz dwa"); - assert_eq!(text, "raz dwa trzy cztery"); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - } - - /// Substitution disagreements keep live (doctrine: floor of truth); the - /// Whisper variant is lexicon/human territory, not a silent overwrite. - #[test] - fn select_composer_transcript_keeps_live_on_substitution() { - let (text, source) = select_composer_transcript(Some("raz dwa trzy"), "raz dwa tszy"); - assert_eq!(text, "raz dwa tszy"); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - } - - /// Collapsing file-final (Apple SFSpeech short) must not blank or shrink a - /// real stream: merge keeps every live token, so the floor survives. - #[test] - fn select_composer_transcript_collapsing_final_keeps_live_floor() { - let stream = "Im wystarczy i jeszcze sporo z freezed live assembly utterance dwa"; - let (text, source) = select_composer_transcript(Some("Im wystarczy"), stream); - assert_eq!(text, stream.trim()); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - } - - /// With no live stream at all, the whisper final stands alone. - #[test] - fn select_composer_transcript_whisper_only_when_stream_empty() { - let (text, source) = select_composer_transcript(Some("raz dwa"), " "); - assert_eq!(text, "raz dwa"); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - } - - /// An absent or empty/whitespace final pass falls back to the streaming - /// splice so a failed adjudication never blanks a real transcript. - #[test] - fn select_composer_transcript_falls_back_to_streaming() { - let (none_text, none_source) = select_composer_transcript(None, " raz dwa "); - assert_eq!(none_text, "raz dwa"); - assert_eq!(none_source, ComposerTranscriptSource::StreamingFallback); - - let (empty_text, empty_source) = select_composer_transcript(Some(" \n "), "raz dwa"); - assert_eq!(empty_text, "raz dwa"); - assert_eq!(empty_source, ComposerTranscriptSource::StreamingFallback); - } - - /// THE ONE RULE for the Smart lane: a `TailGap` result is a **bare tail**, - /// not a full-file transcript. It must be APPENDED to the immutable - /// committed/live streaming text — never diffed against it. `merge_live_whisper` - /// is built for full transcripts vs the live floor: on a bare tail it turns the - /// boundary DeleteA+InsertB pair into a Substitute that keeps live and DISCARDS - /// the whisper token, silently losing gap-fill words. - #[test] - fn compose_composer_transcript_appends_tail_gap() { - let (text, source) = compose_composer_transcript( - ComposerFinalPassPlan::TailGap(1.5), - Some("trzy cztery"), - "raz dwa", - ); - assert_eq!( - text, "raz dwa trzy cztery", - "tail gap-fill must be appended verbatim, not merged" - ); - assert_eq!(source, ComposerTranscriptSource::TailGapAppend); - - // Function words are the first casualty of the merge path. - let (clinical, _) = compose_composer_transcript( - ComposerFinalPassPlan::TailGap(2.0), - Some("i wymioty od rana"), - "Pacjent ma goraczke", - ); - assert_eq!(clinical, "Pacjent ma goraczke i wymioty od rana"); - - // Overlapping preview words are deduped word-granularly, committed side untouched. - let (deduped, _) = compose_composer_transcript( - ComposerFinalPassPlan::TailGap(2.0), - Some("goraczke i wymioty od rana"), - "Pacjent ma goraczke i", - ); - assert_eq!(deduped, "Pacjent ma goraczke i wymioty od rana"); - } - - /// `FullFile` (Always) keeps the whole-WAV merge; `SkipStreaming` never has a - /// final pass to compose with. - #[test] - fn compose_composer_transcript_keeps_merge_for_full_file() { - let (text, source) = compose_composer_transcript( - ComposerFinalPassPlan::FullFile, - Some("raz dwa trzy cztery"), - "raz dwa", - ); - assert_eq!(text, "raz dwa trzy cztery"); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - - let (skipped, skipped_source) = - compose_composer_transcript(ComposerFinalPassPlan::SkipStreaming, None, " raz dwa "); - assert_eq!(skipped, "raz dwa"); - assert_eq!(skipped_source, ComposerTranscriptSource::StreamingFallback); - } - - /// An empty / absent tail leaves the streaming splice exactly as it stands. - #[test] - fn compose_composer_transcript_tail_gap_empty_falls_back() { - for tail in [None, Some(" \n ")] { - let (text, source) = compose_composer_transcript( - ComposerFinalPassPlan::TailGap(1.0), - tail, - " raz dwa ", - ); - assert_eq!( - text, "raz dwa", - "empty tail {tail:?} must not disturb streaming" - ); - assert_eq!(source, ComposerTranscriptSource::StreamingFallback); - } - } - - /// An explicit caller language must map to its two-letter Whisper hint, and - /// `Auto` must collapse to genuine auto-detect (`None`) — never the literal - /// `"auto"` code, which Whisper cannot honour. Guards the manual voice-note - /// language path so the composer respects the persisted language like the - /// hotkey path instead of blind-guessing `en`/`ru`. - #[test] - fn resolve_language_hint_maps_explicit_choices() { - assert_eq!( - resolve_language_hint(Some(CsLanguage::Polish)), - Some("pl".to_string()) - ); - assert_eq!( - resolve_language_hint(Some(CsLanguage::English)), - Some("en".to_string()) - ); - assert_eq!( - resolve_language_hint(Some(CsLanguage::Auto)), - None, - "Auto must be genuine auto-detect (None), never the literal \"auto\" code" - ); - } } diff --git a/core/config/default_env.txt b/core/config/default_env.txt index 6fef8f5b..e6f42455 100644 --- a/core/config/default_env.txt +++ b/core/config/default_env.txt @@ -31,6 +31,11 @@ HOLD_BADGE_SIZE=8 HOLD_BADGE_OFFSET_X=10 HOLD_BADGE_OFFSET_Y=-10 +# Clean committed transcript observer. Unset uses +# $XDG_STATE_HOME/codescribe/transcript-events.jsonl when available, otherwise +# CODESCRIBE_DATA_DIR/transcript-events.jsonl (default ~/.codescribe/...). +# CODESCRIBE_TRANSCRIPT_BUS_PATH= + # Unified runtime pipeline (event-based) is always active. # Golden low-latency / high-fidelity defaults for live preview: CODESCRIBE_STREAM_CHUNK_SEC=3.0 diff --git a/docs/ENV_REGISTRY.toml b/docs/ENV_REGISTRY.toml index dd2fe952..a140fa55 100644 --- a/docs/ENV_REGISTRY.toml +++ b/docs/ENV_REGISTRY.toml @@ -15,8 +15,8 @@ # Created by Vetcoders (c)2026 [meta] -version = "1.1.0" -updated = "2026-07-16" +version = "1.2.0" +updated = "2026-08-15" # ═══════════════════════════════════════════════════════════════════════════════ # VAD (Voice Activity Detection) - Silero Neural Network @@ -1356,6 +1356,20 @@ reload = "restart" category = "storage" description = "Override data directory (default: ~/.codescribe)" +[vars.CODESCRIBE_TRANSCRIPT_BUS_PATH] +default = "" +type = "string" +reload = "restart" +category = "storage" +description = "Override the private append-only clean transcript NDJSON bus; default is $XDG_STATE_HOME/codescribe/transcript-events.jsonl when XDG_STATE_HOME is set, otherwise CODESCRIBE_DATA_DIR/transcript-events.jsonl" + +[vars.XDG_STATE_HOME] +default = "" +type = "string" +reload = "restart" +category = "storage" +description = "Standard XDG state root; when set, owns the default Codescribe clean transcript bus directory" + [vars.VIBECRAFTED_CONTROL_PLANE_DIR] default = "" type = "string" diff --git a/docs/HOTKEYS_CONTRACT.md b/docs/HOTKEYS_CONTRACT.md index 6a178f52..9cbef6ac 100644 --- a/docs/HOTKEYS_CONTRACT.md +++ b/docs/HOTKEYS_CONTRACT.md @@ -30,12 +30,12 @@ repeat cannot emit duplicate commands. This fixed MVP chord is intentionally outside the configurable `WorkMode -> ShortcutBinding` contract. -Assistive bindings use the same app-action plane, but also deliver an Agent -composer capture command (`Start`, `Stop`, or `Toggle`). The existing Agent -window is fronted, its composer is focused, and its existing mic pipeline owns -capture. Assistive never enters `RecordingController` and never shows the -transcription overlay. A single process-wide capture owner makes Agent and -overlay recording mutually exclusive; a competing start fails closed. +Assistive bindings notify the same app-action plane to front/focus the Agent +window, then deliver the recording event to the same `RecordingController` as +Dictation. Agent, Assistive, and Dictation therefore share microphone, VAD, STT, +committed-text correction, and transcript publication. Only downstream UI and +delivery differ. Assistive keeps the Dictation overlay closed; a process-wide +start gate prevents any competing recorder from opening. **Thread routing (operator contract 2026-08-13).** An assistive turn always lands in the thread the Agent rail currently has selected — the thread the @@ -84,8 +84,6 @@ flowchart TB CGEventTap --> HoldGesture CGEventTap --> ToggleGesture - CGEventTap --> ConvGesture - HoldGesture --> HoldEvent ToggleGesture --> ToggleEvent CGEventTap --> CommandGesture @@ -93,7 +91,8 @@ flowchart TB HoldEvent --> AssistiveSplit{"Assistive?"} ToggleEvent --> AssistiveSplit AssistiveSplit -->|No| Handler - AssistiveSplit -->|Yes| AgentCapture["CsAppActionListener
show Agent + composer capture"] + AssistiveSplit -->|Yes| AgentNotice["CsAppActionListener
show Agent"] + AgentNotice --> Handler ShowAgent --> AppAction["CsAppActionListener
showAgent + focus"] Handler --> StateMachine @@ -184,18 +183,13 @@ HotkeyInput { key_type: Toggle, action: Press, assistive: false } // Left Option HotkeyInput { key_type: Toggle, action: Press, assistive: true } // Right Option ``` -### Capture ownership - -Dictation and Formatting use `RecordingController`: capture → live overlay → -final pass → editable overlay delivery. Assistive uses the existing Agent -composer mic pipeline: capture → bounded live preview → explicit live/final -choice → composer delivery. These routes share one atomic capture owner but do -not share presentation surfaces. +### Capture and transcript ownership -The Agent preview keeps the live canvas and any differing final hypothesis. -A materially shorter final result cannot erase live text; a human edit always -wins and cancels Assistive auto-send. The preview remains selectable, -scrollable, and expandable while capture stays inside the Agent window. +Every speech mode enters one `RecordingController`: +capture → VAD → STT → `PresentationEmitter` committed reducer. Dictation and +Formatting then paste or format; Agent and Assistive deliver to the selected +Agent thread. The clean NDJSON transcript bus observes the committed reducer, +before any consumer-specific action, and never opens audio or re-transcribes. Selection is captured in the trigger handler, never at send time. Rust remains the indicator/tray authority, while the transcription overlay is exclusive to @@ -218,16 +212,13 @@ stateDiagram-v2 [*] --> IDLE IDLE --> REC_HOLD : Dictation/Formatting Hold Down - IDLE --> REC_TOGGLE : Dictation/Formatting Toggle - IDLE --> AGENT_CAPTURE : Assistive Start/Toggle + IDLE --> REC_TOGGLE : Dictation/Formatting/Agent Toggle IDLE --> CONVERSATION : Conversation Down
(custom binding) REC_HOLD --> BUSY : Hold Up
(Fn released) REC_HOLD --> REC_HOLD : Shift pressed
(upgrade to assistive) REC_TOGGLE --> BUSY : Toggle again - AGENT_CAPTURE --> IDLE : Assistive Stop/Toggle - CONVERSATION --> IDLE : Conversation Up BUSY --> IDLE : Processing complete
(paste to app) @@ -242,11 +233,6 @@ stateDiagram-v2 Utterance boundary on silence (no stop) end note - note right of AGENT_CAPTURE - Agent composer owns mic - Overlay forbidden - end note - note right of CONVERSATION VAD: Internal (Moshi) Full-duplex audio @@ -258,7 +244,6 @@ stateDiagram-v2 - `IDLE` - Waiting for hotkey - `REC_HOLD` - Recording (hold mode, no VAD) - `REC_TOGGLE` - Recording (toggle mode, VAD active) -- `AGENT_CAPTURE` - Agent composer recording (overlay excluded) - `BUSY` - Processing transcription/AI formatting - `CONVERSATION` - Moshi full-duplex active diff --git a/docs/STT_CONTRACT.md b/docs/STT_CONTRACT.md index 31d65332..a30b0cf4 100644 --- a/docs/STT_CONTRACT.md +++ b/docs/STT_CONTRACT.md @@ -161,12 +161,12 @@ Smart does **not** turn layered on. Off final-pass does **not** force Whisper at ### 3.3 Dictation overlay / tray -| Front | UniFFI | Handler | -| ----------------------------- | -------------------------------------------------- | ------------------------------ | -| Live partials / final text | `CsTranscriptionListener` callbacks | streaming pipeline → listener | -| Dictation service object | `CodescribeDictation` | wraps controller recording API | -| Tray status glyphs | `CodescribeTrayStatus` + listener | controller tray payload | -| Auto-paste / auto-format tray | `set_auto_paste_enabled` / `set_auto_format_level` | `UserSettings` + live toggles | +| Front | UniFFI | Handler | +| ----------------------------- | -------------------------------------------------- | ------------------------------- | +| Live partials / final text | `CsTranscriptionListener` callbacks | streaming pipeline → listener | +| Recording service object | `CodescribeHotkeys` | shared controller recording API | +| Tray status glyphs | `CodescribeTrayStatus` + listener | controller tray payload | +| Auto-paste / auto-format tray | `set_auto_paste_enabled` / `set_auto_format_level` | `UserSettings` + live toggles | ### 3.4 STT engine dispatch (the nit) @@ -239,18 +239,18 @@ unless you accept Apple lottery on every session. ## 6. Full front surface map (non-STT, for completeness) -| Domain | Front / UniFFI | Backend owner | -| ----------------------------- | -------------------------------- | -------------------------------------- | -| Config / keys | `CodescribeConfig` | `core/config/*`, Keychain | -| Hotkeys | `CodescribeHotkeys` | `app/os/hotkeys`, controller | -| Recording / STT | `CodescribeDictation`, listeners | controller + `core/stt` + `core/audio` | -| Agent chat | `CodescribeAgent` | `core/agent/*`, LLM lane | -| Agent delivery (voice→thread) | `CsAgentDeliveryListener` | `ThreadDeliveryGateway` | -| Threads | `CodescribeThreads` | `core/agent/thread_*` | -| MCP | `CodescribeMcpAdmin` | `core/mcp` | -| Quality / lexicon | `quality_*`, lexicon FFI | `core/quality` | -| Notes | `CodescribeNotes` | notes store | -| Tray | `CodescribeTrayStatus` | controller tray | +| Domain | Front / UniFFI | Backend owner | +| ----------------------------- | ------------------------------ | -------------------------------------- | +| Config / keys | `CodescribeConfig` | `core/config/*`, Keychain | +| Hotkeys | `CodescribeHotkeys` | `app/os/hotkeys`, controller | +| Recording / STT | `CodescribeHotkeys`, listeners | controller + `core/stt` + `core/audio` | +| Agent chat | `CodescribeAgent` | `core/agent/*`, LLM lane | +| Agent delivery (voice→thread) | `CsAgentDeliveryListener` | `ThreadDeliveryGateway` | +| Threads | `CodescribeThreads` | `core/agent/thread_*` | +| MCP | `CodescribeMcpAdmin` | `core/mcp` | +| Quality / lexicon | `quality_*`, lexicon FFI | `core/quality` | +| Notes | `CodescribeNotes` | notes store | +| Tray | `CodescribeTrayStatus` | controller tray | --- diff --git a/docs/TRANSCRIPT_BUS.md b/docs/TRANSCRIPT_BUS.md new file mode 100644 index 00000000..4dc24abb --- /dev/null +++ b/docs/TRANSCRIPT_BUS.md @@ -0,0 +1,47 @@ +# Clean Transcript Bus + +Codescribe publishes one private, append-only NDJSON stream from the committed +`PresentationEmitter` reducer. Dictation, Agent, and Assistive share the same +capture, VAD, STT, correction, and publication path; mode changes only the +downstream paste/format/Agent-delivery consumer. + +This bus is an observer. It does not open a microphone, scrape SwiftUI, or +re-transcribe saved audio. + +## Path contract + +Resolution order: + +1. `CODESCRIBE_TRANSCRIPT_BUS_PATH` +2. `$XDG_STATE_HOME/codescribe/transcript-events.jsonl` +3. `$CODESCRIBE_DATA_DIR/transcript-events.jsonl`, with the normal default of + `~/.codescribe/transcript-events.jsonl` + +The parent directory is created when needed. On Unix the file is forced to +mode `0600`. A control-plane bridge can consume it with an ordinary follow/tail +reader; no host, date, room, or control-plane path is embedded in Codescribe. + +## `codescribe.transcript.v1` + +Each line is one JSON object with: + +- `sequence`, `session_id`, `mode`, `utterance_id`, `emitted_at`, `status` +- `sample_rate_hz`, `sample_start`, `sample_end` +- `audio_start_seconds`, `audio_end_seconds` +- committed `text`, structured `segments`, optional `pipeline_session_id` + +Statuses are `session_started`, `utterance_committed`, +`utterance_revised`, and `session_finalized`. A revision keeps the original +utterance identity. `raw_text` and unstable UI previews never cross this +boundary. Every line is flushed before publication returns, so live consumers +can observe committed speech while recording is still active. + +Example consumer: + +```bash +tail -F "$HOME/.codescribe/transcript-events.jsonl" +``` + +That command is the non-XDG default. With `XDG_STATE_HOME` set, follow +`$XDG_STATE_HOME/codescribe/transcript-events.jsonl`; an explicit bus-path +override wins over both. diff --git a/macos/Codescribe/App.swift b/macos/Codescribe/App.swift index 1f3c4263..5cdaa697 100644 --- a/macos/Codescribe/App.swift +++ b/macos/Codescribe/App.swift @@ -24,41 +24,30 @@ private let notesLog = Logger( final class AgentSummonAction { private let store: AgentChatStore private let showAgent: @MainActor () -> Void - private let capture: @MainActor (ComposerCaptureCommand) -> Void init( store: AgentChatStore, - showAgent: @escaping @MainActor () -> Void, - capture: @escaping @MainActor (ComposerCaptureCommand) -> Void = { _ in } + showAgent: @escaping @MainActor () -> Void ) { self.store = store self.showAgent = showAgent - self.capture = capture } func perform() { showAgent() store.requestComposerFocus() } - - func performCapture(_ command: ComposerCaptureCommand) { - perform() - capture(command) - } } /// UniFFI callbacks arrive off-main. This listener performs exactly one hop to /// the AppDelegate-owned action and carries no recording/model payload. final class AgentAppActionListener: CsAppActionListener, @unchecked Sendable { private let summonAgent: @MainActor () -> Void - private let captureAgent: @MainActor (CsAgentCaptureCommand) -> Void init( - summonAgent: @escaping @MainActor () -> Void, - captureAgent: @escaping @MainActor (CsAgentCaptureCommand) -> Void = { _ in } + summonAgent: @escaping @MainActor () -> Void ) { self.summonAgent = summonAgent - self.captureAgent = captureAgent } func onShowAgent() { @@ -68,14 +57,6 @@ final class AgentAppActionListener: CsAppActionListener, @unchecked Sendable { } } } - - func onAgentCapture(command: CsAgentCaptureCommand) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.captureAgent(command) - } - } - } } @main @@ -156,14 +137,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var appActionListener: AgentAppActionListener? private lazy var agentSummonAction = AgentSummonAction( store: model.chat, - showAgent: { [weak self] in self?.showAgent() }, - capture: { [weak self] command in - guard let self else { return } - // A stopped overlay may remain visible for post-capture actions. It - // has no authority in Agent mode and is closed before composer capture. - if !self.model.chat.dictationBlocked { self.model.overlay.hide() } - self.model.chat.handleAssistiveCapture(command) - } + showAgent: { [weak self] in self?.showAgent() } ) private var statusItem: NSStatusItem! private var hasUnreadAgentUpdate = false @@ -721,16 +695,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { summonAgent: { [weak action] in action?.perform() appLogger.info("Agent summon command handled: window fronted and composer focus requested") - }, - captureAgent: { [weak action] command in - let mapped: ComposerCaptureCommand - switch command { - case .start: mapped = .startAssistive - case .stop: mapped = .stopAssistive - case .toggle: mapped = .toggleAssistive - } - action?.performCapture(mapped) - appLogger.info("Assistive hotkey handled by Agent composer microphone") } ) appActionListener = listener diff --git a/macos/Codescribe/Bridge/codescribe_ffi.swift b/macos/Codescribe/Bridge/codescribe_ffi.swift index e02095f9..e22b6173 100644 --- a/macos/Codescribe/Bridge/codescribe_ffi.swift +++ b/macos/Codescribe/Bridge/codescribe_ffi.swift @@ -2183,418 +2183,6 @@ public func FfiConverterTypeCodescribeConfig_lower(_ value: CodescribeConfig) -> -/** - * Thin handle to the codescribe dictation engine (streaming recorder + - * Whisper). Holds the active session behind an async mutex and the current - * foreign listener behind an `RwLock`. - */ -public protocol CodescribeDictationProtocol: AnyObject, Sendable { - - /** - * Optionally warm Whisper weights. Runs on a blocking thread because model - * load touches the GPU and can take seconds. - * - * When the live engine is Apple, Whisper is **gap-fill only** (file final / - * emergency recovery). Missing weights must never refuse recording start — - * we log an honest degraded-mode note and return `Ok(())`. Candle-live - * still requires a model and surfaces load errors. - * Wraps `whisper::init` (stt/whisper/singleton.rs). - */ - func initModel() async throws - - /** - * True when the Whisper engine is currently loaded. May flip back to - * `false` after idle-unload; the next transcription reloads transparently. - * Wraps `whisper::is_initialized` (stt/whisper/singleton.rs:207). - */ - func isModelLoaded() -> Bool - - /** - * True while a dictation session is active. - * Wraps `StreamingRecorder::is_recording` (audio/streaming_recorder.rs:79). - */ - func isRecording() async -> Bool - - /** - * Fire the foreign `on_recording_finalising` callback and publish processing. - */ - func notifyRecordingFinalising() - - /** - * Fire the foreign `on_recording_stopped` callback if a listener is set. - */ - func notifyRecordingStopped() - - /** - * Register (or replace) the foreign listener that receives dictation - * events. Must be called before `start_recording`. - */ - func setListener(listener: CsTranscriptionListener) - - /** - * Start microphone dictation. Builds a `CsEventSink` from the registered - * listener, wires it into a fresh `StreamingRecorder`, and starts the - * event-based transcription session. - * - * Wraps `StreamingRecorder::new` (audio/streaming_recorder.rs:25), - * `set_event_sink` (:74) and `start_event_session` (:87). Errors if no - * listener was set (the core pipeline requires an event sink). - */ - func startRecording(language: CsLanguage?) async throws - - /** - * Stop the active dictation session and return the composed transcript. - * - * Two-phase, within one shared budget (`compose_stop_timeout`): - * - * 1. `StreamingRecorder::stop` is the completion signal — it stops the - * audio stream, joins the transcription task (which only finishes AFTER - * every `UtteranceFinal` has been emitted synchronously into our - * accumulator), and saves the WAV. So the streaming splice is complete - * once stop returns cleanly. - * 2. The final pass `FINAL_PASS_MODE` permits (`composer_final_pass_plan`, - * same law as the controller lane): **Always** re-transcribes the whole - * saved WAV with the `transcribe_file_verdict` adjudicator the - * hotkey/overlay toggle-stop uses; **Smart** transcribes only the audio - * after the last committed utterance and merges it as gap-fill; - * **Off** runs no Whisper at all and streaming is final. - * - * The final pass wins whenever it yields non-empty text; the streaming - * splice is the fallback for a failed/timed-out/empty final pass (or a - * drain timeout, where no WAV is composed). Either way the UI never hangs: - * the shared budget bounds both phases and overrun degrades quality, not - * correctness. The streaming recorder's own transcript buffer is ignored — - * it stays empty on this path. - */ - func stopRecording() async throws -> String - - /** - * Transcribe an existing audio file. Loads + decodes the file, detects the - * language, then runs Whisper. All blocking work runs off the async runtime. - * - * Wraps `audio::load_audio_file` (audio/loader.rs:10), - * `whisper::detect_language` (stt/whisper/singleton.rs:249) and - * `whisper::transcribe` (stt/whisper/singleton.rs:214). - */ - func transcribeFile(path: String) async throws -> CsTranscription - - /** - * Whether the default Whisper weights are on disk / embedded (not necessarily loaded). - */ - func whisperModelReadyStatus() -> CsWhisperModelStatus - -} -/** - * Thin handle to the codescribe dictation engine (streaming recorder + - * Whisper). Holds the active session behind an async mutex and the current - * foreign listener behind an `RwLock`. - */ -open class CodescribeDictation: CodescribeDictationProtocol, @unchecked Sendable { - fileprivate let handle: UInt64 - - /// Used to instantiate a [FFIObject] without an actual handle, for fakes in tests, mostly. -#if swift(>=5.8) - @_documentation(visibility: private) -#endif - public struct NoHandle { - public init() {} - } - - // TODO: We'd like this to be `private` but for Swifty reasons, - // we can't implement `FfiConverter` without making this `required` and we can't - // make it `required` without making it `public`. -#if swift(>=5.8) - @_documentation(visibility: private) -#endif - required public init(unsafeFromHandle handle: UInt64) { - self.handle = handle - } - - // This constructor can be used to instantiate a fake object. - // - Parameter noHandle: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. - // - // - Warning: - // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing handle the FFI lower functions will crash. -#if swift(>=5.8) - @_documentation(visibility: private) -#endif - public init(noHandle: NoHandle) { - self.handle = 0 - } - -#if swift(>=5.8) - @_documentation(visibility: private) -#endif - public func uniffiCloneHandle() -> UInt64 { - return try! rustCall { uniffi_codescribe_ffi_fn_clone_codescribedictation(self.handle, $0) } - } - /** - * Build an idle dictation handle and initialize logging. No microphone or - * model work happens here — call `set_listener` then `start_recording`. - */ -public convenience init() { - let handle = - try! rustCall() { - uniffi_codescribe_ffi_fn_constructor_codescribedictation_new($0 - ) -} - self.init(unsafeFromHandle: handle) -} - - deinit { - try! rustCall { uniffi_codescribe_ffi_fn_free_codescribedictation(handle, $0) } - } - - - - - /** - * Optionally warm Whisper weights. Runs on a blocking thread because model - * load touches the GPU and can take seconds. - * - * When the live engine is Apple, Whisper is **gap-fill only** (file final / - * emergency recovery). Missing weights must never refuse recording start — - * we log an honest degraded-mode note and return `Ok(())`. Candle-live - * still requires a model and surfaces load errors. - * Wraps `whisper::init` (stt/whisper/singleton.rs). - */ -open func initModel()async throws { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_codescribe_ffi_fn_method_codescribedictation_init_model( - self.uniffiCloneHandle() - - ) - }, - pollFunc: ffi_codescribe_ffi_rust_future_poll_void, - completeFunc: ffi_codescribe_ffi_rust_future_complete_void, - freeFunc: ffi_codescribe_ffi_rust_future_free_void, - liftFunc: { $0 }, - errorHandler: FfiConverterTypeCsError_lift - ) -} - - /** - * True when the Whisper engine is currently loaded. May flip back to - * `false` after idle-unload; the next transcription reloads transparently. - * Wraps `whisper::is_initialized` (stt/whisper/singleton.rs:207). - */ -open func isModelLoaded() -> Bool { - return try! FfiConverterBool.lift(try! rustCall() { - uniffi_codescribe_ffi_fn_method_codescribedictation_is_model_loaded( - self.uniffiCloneHandle(),$0 - ) -}) -} - - /** - * True while a dictation session is active. - * Wraps `StreamingRecorder::is_recording` (audio/streaming_recorder.rs:79). - */ -open func isRecording()async -> Bool { - return - try! await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_codescribe_ffi_fn_method_codescribedictation_is_recording( - self.uniffiCloneHandle() - - ) - }, - pollFunc: ffi_codescribe_ffi_rust_future_poll_i8, - completeFunc: ffi_codescribe_ffi_rust_future_complete_i8, - freeFunc: ffi_codescribe_ffi_rust_future_free_i8, - liftFunc: FfiConverterBool.lift, - errorHandler: nil - - ) -} - - /** - * Fire the foreign `on_recording_finalising` callback and publish processing. - */ -open func notifyRecordingFinalising() {try! rustCall() { - uniffi_codescribe_ffi_fn_method_codescribedictation_notify_recording_finalising( - self.uniffiCloneHandle(),$0 - ) -} -} - - /** - * Fire the foreign `on_recording_stopped` callback if a listener is set. - */ -open func notifyRecordingStopped() {try! rustCall() { - uniffi_codescribe_ffi_fn_method_codescribedictation_notify_recording_stopped( - self.uniffiCloneHandle(),$0 - ) -} -} - - /** - * Register (or replace) the foreign listener that receives dictation - * events. Must be called before `start_recording`. - */ -open func setListener(listener: CsTranscriptionListener) {try! rustCall() { - uniffi_codescribe_ffi_fn_method_codescribedictation_set_listener( - self.uniffiCloneHandle(), - FfiConverterTypeCsTranscriptionListener_lower(listener),$0 - ) -} -} - - /** - * Start microphone dictation. Builds a `CsEventSink` from the registered - * listener, wires it into a fresh `StreamingRecorder`, and starts the - * event-based transcription session. - * - * Wraps `StreamingRecorder::new` (audio/streaming_recorder.rs:25), - * `set_event_sink` (:74) and `start_event_session` (:87). Errors if no - * listener was set (the core pipeline requires an event sink). - */ -open func startRecording(language: CsLanguage?)async throws { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_codescribe_ffi_fn_method_codescribedictation_start_recording( - self.uniffiCloneHandle(), - FfiConverterOptionTypeCsLanguage.lower(language) - ) - }, - pollFunc: ffi_codescribe_ffi_rust_future_poll_void, - completeFunc: ffi_codescribe_ffi_rust_future_complete_void, - freeFunc: ffi_codescribe_ffi_rust_future_free_void, - liftFunc: { $0 }, - errorHandler: FfiConverterTypeCsError_lift - ) -} - - /** - * Stop the active dictation session and return the composed transcript. - * - * Two-phase, within one shared budget (`compose_stop_timeout`): - * - * 1. `StreamingRecorder::stop` is the completion signal — it stops the - * audio stream, joins the transcription task (which only finishes AFTER - * every `UtteranceFinal` has been emitted synchronously into our - * accumulator), and saves the WAV. So the streaming splice is complete - * once stop returns cleanly. - * 2. The final pass `FINAL_PASS_MODE` permits (`composer_final_pass_plan`, - * same law as the controller lane): **Always** re-transcribes the whole - * saved WAV with the `transcribe_file_verdict` adjudicator the - * hotkey/overlay toggle-stop uses; **Smart** transcribes only the audio - * after the last committed utterance and merges it as gap-fill; - * **Off** runs no Whisper at all and streaming is final. - * - * The final pass wins whenever it yields non-empty text; the streaming - * splice is the fallback for a failed/timed-out/empty final pass (or a - * drain timeout, where no WAV is composed). Either way the UI never hangs: - * the shared budget bounds both phases and overrun degrades quality, not - * correctness. The streaming recorder's own transcript buffer is ignored — - * it stays empty on this path. - */ -open func stopRecording()async throws -> String { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_codescribe_ffi_fn_method_codescribedictation_stop_recording( - self.uniffiCloneHandle() - - ) - }, - pollFunc: ffi_codescribe_ffi_rust_future_poll_rust_buffer, - completeFunc: ffi_codescribe_ffi_rust_future_complete_rust_buffer, - freeFunc: ffi_codescribe_ffi_rust_future_free_rust_buffer, - liftFunc: FfiConverterString.lift, - errorHandler: FfiConverterTypeCsError_lift - ) -} - - /** - * Transcribe an existing audio file. Loads + decodes the file, detects the - * language, then runs Whisper. All blocking work runs off the async runtime. - * - * Wraps `audio::load_audio_file` (audio/loader.rs:10), - * `whisper::detect_language` (stt/whisper/singleton.rs:249) and - * `whisper::transcribe` (stt/whisper/singleton.rs:214). - */ -open func transcribeFile(path: String)async throws -> CsTranscription { - return - try await uniffiRustCallAsync( - rustFutureFunc: { - uniffi_codescribe_ffi_fn_method_codescribedictation_transcribe_file( - self.uniffiCloneHandle(), - FfiConverterString.lower(path) - ) - }, - pollFunc: ffi_codescribe_ffi_rust_future_poll_rust_buffer, - completeFunc: ffi_codescribe_ffi_rust_future_complete_rust_buffer, - freeFunc: ffi_codescribe_ffi_rust_future_free_rust_buffer, - liftFunc: FfiConverterTypeCsTranscription_lift, - errorHandler: FfiConverterTypeCsError_lift - ) -} - - /** - * Whether the default Whisper weights are on disk / embedded (not necessarily loaded). - */ -open func whisperModelReadyStatus() -> CsWhisperModelStatus { - return try! FfiConverterTypeCsWhisperModelStatus_lift(try! rustCall() { - uniffi_codescribe_ffi_fn_method_codescribedictation_whisper_model_ready_status( - self.uniffiCloneHandle(),$0 - ) -}) -} - - - -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeCodescribeDictation: FfiConverter { - typealias FfiType = UInt64 - typealias SwiftType = CodescribeDictation - - public static func lift(_ handle: UInt64) throws -> CodescribeDictation { - return CodescribeDictation(unsafeFromHandle: handle) - } - - public static func lower(_ value: CodescribeDictation) -> UInt64 { - return value.uniffiCloneHandle() - } - - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CodescribeDictation { - let handle: UInt64 = try readInt(&buf) - return try lift(handle) - } - - public static func write(_ value: CodescribeDictation, into buf: inout [UInt8]) { - writeInt(&buf, lower(value)) - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeCodescribeDictation_lift(_ handle: UInt64) throws -> CodescribeDictation { - return try FfiConverterTypeCodescribeDictation.lift(handle) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeCodescribeDictation_lower(_ value: CodescribeDictation) -> UInt64 { - return FfiConverterTypeCodescribeDictation.lower(value) -} - - - - - - /** * Process-global hotkey runtime owner. * @@ -2725,12 +2313,6 @@ public protocol CodescribeHotkeysProtocol: AnyObject, Sendable { */ func sendAssistiveTranscript(text: String) async throws -> Bool - /** - * Atomically claim/release the one process-wide capture owner. Returns - * false when the legacy overlay already owns the microphone. - */ - func setAgentCaptureActive(active: Bool) -> Bool - /** * Register the Swift AgentChat listener that renders voice-assistive replies * live. Process-global, so it takes effect for the delivery forwarder spawned @@ -3181,19 +2763,6 @@ open func sendAssistiveTranscript(text: String)async throws -> Bool { ) } - /** - * Atomically claim/release the one process-wide capture owner. Returns - * false when the legacy overlay already owns the microphone. - */ -open func setAgentCaptureActive(active: Bool) -> Bool { - return try! FfiConverterBool.lift(try! rustCall() { - uniffi_codescribe_ffi_fn_method_codescribehotkeys_set_agent_capture_active( - self.uniffiCloneHandle(), - FfiConverterBool.lower(active),$0 - ) -}) -} - /** * Register the Swift AgentChat listener that renders voice-assistive replies * live. Process-global, so it takes effect for the delivery forwarder spawned @@ -5491,12 +5060,6 @@ public protocol CsAppActionListener: AnyObject, Sendable { */ func onShowAgent() - /** - * Drive the Agent-owned composer microphone. The bridge has already claimed - * (or verified) capture ownership before this fires. - */ - func onAgentCapture(command: CsAgentCaptureCommand) - } /** * Foreign callback for UI-only global commands. These actions are deliberately @@ -5561,18 +5124,6 @@ open func onShowAgent() {try! rustCall() { } } - /** - * Drive the Agent-owned composer microphone. The bridge has already claimed - * (or verified) capture ownership before this fires. - */ -open func onAgentCapture(command: CsAgentCaptureCommand) {try! rustCall() { - uniffi_codescribe_ffi_fn_method_csappactionlistener_on_agent_capture( - self.uniffiCloneHandle(), - FfiConverterTypeCsAgentCaptureCommand_lower(command),$0 - ) -} -} - } @@ -5617,30 +5168,6 @@ fileprivate struct UniffiCallbackInterfaceCsAppActionListener { } - let writeReturn = { () } - uniffiTraitInterfaceCall( - callStatus: uniffiCallStatus, - makeCall: makeCall, - writeReturn: writeReturn - ) - }, - onAgentCapture: { ( - uniffiHandle: UInt64, - command: RustBuffer, - uniffiOutReturn: UnsafeMutableRawPointer, - uniffiCallStatus: UnsafeMutablePointer - ) in - let makeCall = { - () throws -> () in - guard let uniffiObj = try? FfiConverterTypeCsAppActionListener.handleMap.get(handle: uniffiHandle) else { - throw UniffiInternalError.unexpectedStaleHandle - } - return uniffiObj.onAgentCapture( - command: try FfiConverterTypeCsAgentCaptureCommand_lift(command) - ) - } - - let writeReturn = { () } uniffiTraitInterfaceCall( callStatus: uniffiCallStatus, @@ -11295,83 +10822,6 @@ public func FfiConverterTypeCsWhisperModelStatus_lower(_ value: CsWhisperModelSt return FfiConverterTypeCsWhisperModelStatus.lower(value) } -// Note that we don't yet support `indirect` for enums. -// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. -/** - * UI commands for the Agent-owned composer microphone. Assistive hotkeys are - * translated here, before the legacy RecordingController can prepare/show its - * overlay, so there is exactly one Assistive capture owner. - */ - -public enum CsAgentCaptureCommand: Equatable, Hashable { - - case start - case stop - case toggle - - - -} - -#if compiler(>=6) -extension CsAgentCaptureCommand: Sendable {} -#endif - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public struct FfiConverterTypeCsAgentCaptureCommand: FfiConverterRustBuffer { - typealias SwiftType = CsAgentCaptureCommand - - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CsAgentCaptureCommand { - let variant: Int32 = try readInt(&buf) - switch variant { - - case 1: return .start - - case 2: return .stop - - case 3: return .toggle - - default: throw UniffiInternalError.unexpectedEnumCase - } - } - - public static func write(_ value: CsAgentCaptureCommand, into buf: inout [UInt8]) { - switch value { - - - case .start: - writeInt(&buf, Int32(1)) - - - case .stop: - writeInt(&buf, Int32(2)) - - - case .toggle: - writeInt(&buf, Int32(3)) - - } - } -} - - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeCsAgentCaptureCommand_lift(_ buf: RustBuffer) throws -> CsAgentCaptureCommand { - return try FfiConverterTypeCsAgentCaptureCommand.lift(buf) -} - -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -public func FfiConverterTypeCsAgentCaptureCommand_lower(_ value: CsAgentCaptureCommand) -> RustBuffer { - return FfiConverterTypeCsAgentCaptureCommand.lower(value) -} - - // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. /** @@ -14009,36 +13459,6 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_update_config_many() != 23821) { return InitializationResult.apiChecksumMismatch } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_init_model() != 36342) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_is_model_loaded() != 16019) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_is_recording() != 44927) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_notify_recording_finalising() != 54267) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_notify_recording_stopped() != 16148) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_set_listener() != 48324) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_start_recording() != 14108) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_stop_recording() != 3278) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_transcribe_file() != 13892) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_codescribe_ffi_checksum_method_codescribedictation_whisper_model_ready_status() != 38237) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_available_bindings() != 35701) { return InitializationResult.apiChecksumMismatch } @@ -14090,9 +13510,6 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_send_assistive_transcript() != 10588) { return InitializationResult.apiChecksumMismatch } - if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_agent_capture_active() != 8943) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_agent_delivery_listener() != 36044) { return InitializationResult.apiChecksumMismatch } @@ -14267,9 +13684,6 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_method_csappactionlistener_on_show_agent() != 40684) { return InitializationResult.apiChecksumMismatch } - if (uniffi_codescribe_ffi_checksum_method_csappactionlistener_on_agent_capture() != 63731) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_codescribe_ffi_checksum_method_cstranscriptionlistener_on_recording_preparing() != 27049) { return InitializationResult.apiChecksumMismatch } @@ -14336,9 +13750,6 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_constructor_codescribeconfig_new() != 56915) { return InitializationResult.apiChecksumMismatch } - if (uniffi_codescribe_ffi_checksum_constructor_codescribedictation_new() != 62362) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_codescribe_ffi_checksum_constructor_codescribehotkeys_new() != 29673) { return InitializationResult.apiChecksumMismatch } diff --git a/macos/Codescribe/Bridge/codescribe_ffiFFI.h b/macos/Codescribe/Bridge/codescribe_ffiFFI.h index c90a36da..c2e73da1 100644 --- a/macos/Codescribe/Bridge/codescribe_ffiFFI.h +++ b/macos/Codescribe/Bridge/codescribe_ffiFFI.h @@ -368,13 +368,6 @@ typedef void (*UniffiCallbackInterfaceCsAppActionListenerMethod0)(uint64_t, void RustCallStatus *_Nonnull uniffiCallStatus ); -#endif -#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_CS_APP_ACTION_LISTENER_METHOD1 -#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_CS_APP_ACTION_LISTENER_METHOD1 -typedef void (*UniffiCallbackInterfaceCsAppActionListenerMethod1)(uint64_t, RustBuffer, void* _Nonnull, - RustCallStatus *_Nonnull uniffiCallStatus - ); - #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_CS_TRANSCRIPTION_LISTENER_METHOD0 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_CS_TRANSCRIPTION_LISTENER_METHOD0 @@ -548,7 +541,6 @@ typedef struct UniffiVTableCallbackInterfaceCsAppActionListener { UniffiCallbackInterfaceFree _Nonnull uniffiFree; UniffiCallbackInterfaceClone _Nonnull uniffiClone; UniffiCallbackInterfaceCsAppActionListenerMethod0 _Nonnull onShowAgent; - UniffiCallbackInterfaceCsAppActionListenerMethod1 _Nonnull onAgentCapture; } UniffiVTableCallbackInterfaceCsAppActionListener; #endif @@ -923,72 +915,6 @@ void uniffi_codescribe_ffi_fn_method_codescribeconfig_update_config(uint64_t ptr void uniffi_codescribe_ffi_fn_method_codescribeconfig_update_config_many(uint64_t ptr, RustBuffer entries, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_CLONE_CODESCRIBEDICTATION -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_CLONE_CODESCRIBEDICTATION -uint64_t uniffi_codescribe_ffi_fn_clone_codescribedictation(uint64_t handle, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FREE_CODESCRIBEDICTATION -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FREE_CODESCRIBEDICTATION -void uniffi_codescribe_ffi_fn_free_codescribedictation(uint64_t handle, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_CONSTRUCTOR_CODESCRIBEDICTATION_NEW -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_CONSTRUCTOR_CODESCRIBEDICTATION_NEW -uint64_t uniffi_codescribe_ffi_fn_constructor_codescribedictation_new(RustCallStatus *_Nonnull out_status - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_INIT_MODEL -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_INIT_MODEL -uint64_t uniffi_codescribe_ffi_fn_method_codescribedictation_init_model(uint64_t ptr -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_IS_MODEL_LOADED -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_IS_MODEL_LOADED -int8_t uniffi_codescribe_ffi_fn_method_codescribedictation_is_model_loaded(uint64_t ptr, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_IS_RECORDING -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_IS_RECORDING -uint64_t uniffi_codescribe_ffi_fn_method_codescribedictation_is_recording(uint64_t ptr -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_NOTIFY_RECORDING_FINALISING -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_NOTIFY_RECORDING_FINALISING -void uniffi_codescribe_ffi_fn_method_codescribedictation_notify_recording_finalising(uint64_t ptr, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_NOTIFY_RECORDING_STOPPED -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_NOTIFY_RECORDING_STOPPED -void uniffi_codescribe_ffi_fn_method_codescribedictation_notify_recording_stopped(uint64_t ptr, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_SET_LISTENER -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_SET_LISTENER -void uniffi_codescribe_ffi_fn_method_codescribedictation_set_listener(uint64_t ptr, uint64_t listener, RustCallStatus *_Nonnull out_status -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_START_RECORDING -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_START_RECORDING -uint64_t uniffi_codescribe_ffi_fn_method_codescribedictation_start_recording(uint64_t ptr, RustBuffer language -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_STOP_RECORDING -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_STOP_RECORDING -uint64_t uniffi_codescribe_ffi_fn_method_codescribedictation_stop_recording(uint64_t ptr -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_TRANSCRIBE_FILE -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_TRANSCRIBE_FILE -uint64_t uniffi_codescribe_ffi_fn_method_codescribedictation_transcribe_file(uint64_t ptr, RustBuffer path -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_WHISPER_MODEL_READY_STATUS -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEDICTATION_WHISPER_MODEL_READY_STATUS -RustBuffer uniffi_codescribe_ffi_fn_method_codescribedictation_whisper_model_ready_status(uint64_t ptr, RustCallStatus *_Nonnull out_status -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_CLONE_CODESCRIBEHOTKEYS #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_CLONE_CODESCRIBEHOTKEYS uint64_t uniffi_codescribe_ffi_fn_clone_codescribehotkeys(uint64_t handle, RustCallStatus *_Nonnull out_status @@ -1090,11 +1016,6 @@ void uniffi_codescribe_ffi_fn_method_codescribehotkeys_reset_bindings_to_default uint64_t uniffi_codescribe_ffi_fn_method_codescribehotkeys_send_assistive_transcript(uint64_t ptr, RustBuffer text ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_SET_AGENT_CAPTURE_ACTIVE -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_SET_AGENT_CAPTURE_ACTIVE -int8_t uniffi_codescribe_ffi_fn_method_codescribehotkeys_set_agent_capture_active(uint64_t ptr, int8_t active, RustCallStatus *_Nonnull out_status -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_SET_AGENT_DELIVERY_LISTENER #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_SET_AGENT_DELIVERY_LISTENER void uniffi_codescribe_ffi_fn_method_codescribehotkeys_set_agent_delivery_listener(uint64_t ptr, uint64_t listener, RustCallStatus *_Nonnull out_status @@ -1494,11 +1415,6 @@ void uniffi_codescribe_ffi_fn_init_callback_vtable_csappactionlistener(const Uni void uniffi_codescribe_ffi_fn_method_csappactionlistener_on_show_agent(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CSAPPACTIONLISTENER_ON_AGENT_CAPTURE -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CSAPPACTIONLISTENER_ON_AGENT_CAPTURE -void uniffi_codescribe_ffi_fn_method_csappactionlistener_on_agent_capture(uint64_t ptr, RustBuffer command, RustCallStatus *_Nonnull out_status -); -#endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_CLONE_CSTRANSCRIPTIONLISTENER #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_CLONE_CSTRANSCRIPTIONLISTENER uint64_t uniffi_codescribe_ffi_fn_clone_cstranscriptionlistener(uint64_t handle, RustCallStatus *_Nonnull out_status @@ -2417,66 +2333,6 @@ uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_update_config(vo #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_UPDATE_CONFIG_MANY uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_update_config_many(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_INIT_MODEL -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_INIT_MODEL -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_init_model(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_IS_MODEL_LOADED -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_IS_MODEL_LOADED -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_is_model_loaded(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_IS_RECORDING -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_IS_RECORDING -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_is_recording(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_NOTIFY_RECORDING_FINALISING -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_NOTIFY_RECORDING_FINALISING -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_notify_recording_finalising(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_NOTIFY_RECORDING_STOPPED -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_NOTIFY_RECORDING_STOPPED -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_notify_recording_stopped(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_SET_LISTENER -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_SET_LISTENER -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_set_listener(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_START_RECORDING -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_START_RECORDING -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_start_recording(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_STOP_RECORDING -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_STOP_RECORDING -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_stop_recording(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_TRANSCRIBE_FILE -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_TRANSCRIBE_FILE -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_transcribe_file(void - -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_WHISPER_MODEL_READY_STATUS -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEDICTATION_WHISPER_MODEL_READY_STATUS -uint16_t uniffi_codescribe_ffi_checksum_method_codescribedictation_whisper_model_ready_status(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_AVAILABLE_BINDINGS @@ -2579,12 +2435,6 @@ uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_reset_bindings_ #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_SEND_ASSISTIVE_TRANSCRIPT uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_send_assistive_transcript(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_SET_AGENT_CAPTURE_ACTIVE -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_SET_AGENT_CAPTURE_ACTIVE -uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_agent_capture_active(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_SET_AGENT_DELIVERY_LISTENER @@ -2933,12 +2783,6 @@ uint16_t uniffi_codescribe_ffi_checksum_method_csagentlistener_on_error(void #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CSAPPACTIONLISTENER_ON_SHOW_AGENT uint16_t uniffi_codescribe_ffi_checksum_method_csappactionlistener_on_show_agent(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CSAPPACTIONLISTENER_ON_AGENT_CAPTURE -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CSAPPACTIONLISTENER_ON_AGENT_CAPTURE -uint16_t uniffi_codescribe_ffi_checksum_method_csappactionlistener_on_agent_capture(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CSTRANSCRIPTIONLISTENER_ON_RECORDING_PREPARING @@ -3071,12 +2915,6 @@ uint16_t uniffi_codescribe_ffi_checksum_constructor_codescribeagentstatus_new(vo #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_CONSTRUCTOR_CODESCRIBECONFIG_NEW uint16_t uniffi_codescribe_ffi_checksum_constructor_codescribeconfig_new(void -); -#endif -#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_CONSTRUCTOR_CODESCRIBEDICTATION_NEW -#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_CONSTRUCTOR_CODESCRIBEDICTATION_NEW -uint16_t uniffi_codescribe_ffi_checksum_constructor_codescribedictation_new(void - ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_CONSTRUCTOR_CODESCRIBEHOTKEYS_NEW diff --git a/macos/Codescribe/Core/AppModel.swift b/macos/Codescribe/Core/AppModel.swift index d1db97b2..1e16ba68 100644 --- a/macos/Codescribe/Core/AppModel.swift +++ b/macos/Codescribe/Core/AppModel.swift @@ -50,11 +50,10 @@ final class AppModel: ObservableObject { mcpAdmin: RealMCPAdminEngine() ) self.chat = chat - self.overlay = OverlayController(store: chat, engine: ControllerDictationEngine()) + self.overlay = OverlayController(engine: ControllerDictationEngine()) self.tray = TrayViewModel(engine: RealTrayEngine()) - // AgentChatStore/composer is the sole Assistive route owner. Its existing - // dictation adapter is also used by the composer mic button; legacy - // Dictation/Formatting stay on RecordingController + overlay. + // The composer is a gesture-only adapter over RecordingController. Right + // Option, composer mic, Dictation, and Formatting share one recorder/STT. chat.dictation = RealComposerDictation(store: chat) AgentPerf.log("app bootstrap (AppModel init)", since: bootstrapStart) } @@ -66,7 +65,6 @@ final class AppModel: ObservableObject { @MainActor final class OverlayController: ObservableObject { let state: OverlayState - private weak var store: AgentChatStore? /// Independent text scale for the dictation overlay (⌘+/-/0 while the panel is /// key). Separate from the chat scale so a distance-readable transcript and an /// up-close chat can be tuned independently. @@ -84,7 +82,6 @@ final class OverlayController: ObservableObject { private var sessionWasAssistive = false init( - store: AgentChatStore? = nil, state: OverlayState? = nil, engine: DictationEngine? = nil, overlayEnabledProvider: @escaping () -> Bool = { @@ -99,7 +96,6 @@ final class OverlayController: ObservableObject { ) { let state = state ?? OverlayState() self.state = state - self.store = store self.overlayEnabledProvider = overlayEnabledProvider self.assistiveStatusProvider = assistiveStatusProvider self.panelFactory = @@ -118,6 +114,9 @@ final class OverlayController: ObservableObject { self.sessionWasAssistive = false self.refreshAssistiveLatch() self.showForRecording() + if self.sessionWasAssistive { + AppModel.shared.chat.setDictationPhase(.preparing) + } AppModel.shared.tray.isStartingDictation = true // Block the composer mic while the shared recorder owns the microphone. AppModel.shared.chat.dictationBlocked = true @@ -126,6 +125,9 @@ final class OverlayController: ObservableObject { guard let self else { return } self.refreshAssistiveLatch() self.showForRecording() + if self.sessionWasAssistive { + AppModel.shared.chat.setDictationPhase(.recording) + } AppModel.shared.tray.isRecording = true AppModel.shared.tray.isStartingDictation = false AppModel.shared.chat.dictationBlocked = true @@ -133,6 +135,9 @@ final class OverlayController: ObservableObject { state.onRecordingStopped = { [weak self] in guard let self else { return } self.refreshAssistiveLatch() + if self.sessionWasAssistive { + AppModel.shared.chat.setDictationPhase(.idle) + } self.markStopped() AppModel.shared.tray.isRecording = false AppModel.shared.tray.isStartingDictation = false @@ -168,7 +173,7 @@ final class OverlayController: ObservableObject { /// hiding the overlay never suppresses the paste. func showForRecording() { refreshAssistiveLatch() - guard !agentCaptureOwnsMicrophone, !sessionWasAssistive else { + guard !sessionWasAssistive else { hide() return } @@ -180,10 +185,6 @@ final class OverlayController: ObservableObject { } func show() { - guard !agentCaptureOwnsMicrophone else { - hide() - return - } let panel = panel ?? panelFactory(state, textScale) self.panel = panel // A pending fade-out must not leave a freshly shown panel invisible. @@ -222,8 +223,8 @@ final class OverlayController: ObservableObject { state.finishControllerRecording() } - /// Called by the live TrayStatusStore listener. Assistive is Agent-owned and - /// therefore forces the transcription overlay closed. + /// Called by the live TrayStatusStore listener. Assistive uses the shared + /// controller but keeps the Dictation overlay closed in favor of Agent UI. func handleIndicatorModeChange(_ mode: CsIndicatorMode) { if mode == .assistive { sessionWasAssistive = true @@ -241,11 +242,6 @@ final class OverlayController: ObservableObject { handleAssistiveStatusChange(assistiveStatusProvider()) } - private var agentCaptureOwnsMicrophone: Bool { - guard let store else { return false } - return store.dictationPhase == .preparing || store.dictationPhase == .recording - } - func hide() { // Persist the user's chosen size for next launch (replaces frame autosave, // which used to write back the old feedback loop's runaway sizes) — and, diff --git a/macos/Codescribe/Core/ComposerDictation.swift b/macos/Codescribe/Core/ComposerDictation.swift index 89c71e77..61d1a0e8 100644 --- a/macos/Codescribe/Core/ComposerDictation.swift +++ b/macos/Codescribe/Core/ComposerDictation.swift @@ -1,41 +1,20 @@ import Foundation import OSLog -/// Diagnostic breadcrumbs for the composer voice-note path. Filter with: -/// log show --predicate 'subsystem == "com.vetcoders.codescribe"' --info +/// Diagnostic breadcrumbs for Agent voice capture. Audio, STT, corrections, +/// transcript publication, and delivery are all owned by RecordingController. private let dictationLog = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", category: "composer-dictation" ) -/// Real composer dictation adapter: a thin driver over the `CodescribeDictation` -/// UniFFI bridge (the SAME streaming recorder + Whisper stack the overlay uses, -/// on an independent recorder handle). Click-to-start / click-to-stop inserts -/// into the draft without auto-send. Assistive hotkeys route here via -/// `handle(_:)` and may auto-send on stop unless the user edits the preview -/// (`resolveDictationDelivery` cancels auto-send for edited text). -/// -/// Process-wide capture ownership lives in the bridge (`CAPTURE_OWNER` -/// none/overlay/agent via `setAgentCaptureActive`). Claim fails closed when the -/// overlay owns the mic; overlay start fails closed while agent owns capture. -/// `store.dictationBlocked` is an additional UI guard for a live overlay session. -/// -/// Live partials stream into `dictationPreview`; `stopRecording()` is the only -/// path that commits into the editable draft. When final text regresses against -/// a longer live canvas, delivery keeps live and preserves the final alternative. +/// Thin UI gesture adapter over the shared controller. The composer never owns +/// a recorder or transcript reducer; it merely requests the Agent toggle route. @MainActor final class RealComposerDictation: ComposerDictating { - private let dictation = CodescribeDictation() private let hotkeys = CodescribeHotkeys() private weak var store: AgentChatStore? - /// Strong ref so the foreign listener outlives the Rust-side `Arc` handoff. - private var listener: ComposerDictationListener? - /// Whisper is idempotently loaded once, then reused for later notes. - private var modelReady = false - /// Guards against re-entrant toggles while an async start/stop is in flight. private var transitioning = false - private var autoSendOnStop = false - private var pendingStopAfterStart = false init(store: AgentChatStore) { self.store = store @@ -43,319 +22,23 @@ final class RealComposerDictation: ComposerDictating { func toggle() { guard let store, !transitioning else { return } - switch store.dictationPhase { - case .recording: - stop() - case .idle, .failed: - start(autoSend: false) - case .preparing: - break // mid-transition — ignore until it settles - } - } - - func handle(_ command: ComposerCaptureCommand) { - guard let store else { return } - if transitioning { - switch command { - case .stopAssistive, .toggleAssistive: - pendingStopAfterStart = true - case .startAssistive: - break - } - return - } - switch command { - case .startAssistive: - if store.dictationPhase != .recording { start(autoSend: true) } - case .stopAssistive: - if store.dictationPhase == .recording { stop() } - case .toggleAssistive: - if store.dictationPhase == .recording { stop() } else { start(autoSend: true) } - } - } - - private func start(autoSend: Bool) { - guard let store else { return } - // Collision guard: a hotkey/tray/overlay dictation session owns the mic. - if store.dictationBlocked { - store.reportDictationFailure("Microphone is busy with a shortcut dictation.") - return - } transitioning = true - autoSendOnStop = autoSend - store.beginDictationPreviewSession() store.setDictationPhase(.preparing) - guard hotkeys.setAgentCaptureActive(active: true) else { - transitioning = false - store.reportDictationFailure("Transcription overlay already owns the microphone") - return - } Task { @MainActor in - defer { - transitioning = false - if pendingStopAfterStart { - pendingStopAfterStart = false - if store.dictationPhase == .recording { - Task { @MainActor [weak self] in self?.stop() } - } - } - } - guard await Self.ensureMicPermission() else { - _ = hotkeys.setAgentCaptureActive(active: false) - store.reportDictationFailure( - "Microphone access is off — enable it in System Settings › Privacy & Security.") - return - } - // Register a fresh listener (held strongly here) before starting; the - // bridge rejects `startRecording` without one. - let listener = ComposerDictationListener(store: store) { [weak self] message in - Task { @MainActor [weak self] in - self?.handleEngineError(message: message) - } - } - self.listener = listener - dictation.setListener(listener: listener) + defer { transitioning = false } do { - // Optional Whisper warm: Apple-live must start even when weights are - // missing (gap-fill degraded for the session). Bridge initModel is - // soft-fail for Apple; keep recording start unblocked either way. - if !modelReady { - do { - try await dictation.initModel() - modelReady = true - } catch { - dictationLog.warning( - "composer dictation: Whisper warm skipped (degraded gap-fill): \(error.localizedDescription, privacy: .public)" - ) - // Leave modelReady false so a later session can retry. - } - } - try await dictation.startRecording(language: nil) // auto-detect language - store.setDictationPhase(.recording) - dictationLog.info("composer dictation: recording started") - } catch { - _ = hotkeys.setAgentCaptureActive(active: false) - dictationLog.error( - "composer dictation start failed: \(error.localizedDescription, privacy: .public)") - store.clearDictationPreview() - store.reportDictationFailure("Couldn't start recording: \(error.localizedDescription)") - } - } - } - - private func stop() { - guard let store else { return } - transitioning = true - store.setDictationPhase(.preparing) - Task { @MainActor in - defer { - transitioning = false - _ = hotkeys.setAgentCaptureActive(active: false) - } - do { - let transcript = try await dictation.stopRecording() - let resolution = store.resolveDictationDelivery( - final: transcript, - autoSend: autoSendOnStop - ) - let trimmed = resolution.text - if trimmed.isEmpty { - dictationLog.info("composer dictation: stopped with empty transcript") - store.clearDictationPreview() - store.reportDictationFailure("No speech detected.") + if store.dictationBlocked { + try await hotkeys.stopRecording() + dictationLog.info("Agent voice capture stop requested on shared controller") } else { - store.setDictationPhase(.idle) - var deliveredViaVoiceLane = false - if resolution.autoSend { - // Voice lane first: the controller attaches the - // trigger-time selection context and the context bucket - // (HOTKEYS_CONTRACT "captured in the trigger handler"), - // and the turn streams as a core-owned voice turn — the - // composer FIFO already skips threads with an active - // voice turn. A plain `store.send()` here delivered the - // spoken text alone (review P0-02). - deliveredViaVoiceLane = - (try? await hotkeys.sendAssistiveTranscript(text: trimmed)) ?? false - } - if deliveredViaVoiceLane { - store.clearDictationPreview() - dictationLog.info( - "composer dictation: assistive turn delivered via voice lane") - } else { - store.appendDictatedTranscript(trimmed) - if resolution.autoSend { - store.send() - } - dictationLog.info( - "composer dictation: inserted \(trimmed.count, privacy: .public) chars") - } - // Analytics must never delay transcript delivery or agent send. - Task { @MainActor in - _ = await ActivationPing.shared.recordFirstSuccessfulDictation() - } + try await hotkeys.startAssistiveRecording() + dictationLog.info("Agent voice capture start requested on shared controller") } } catch { dictationLog.error( - "composer dictation stop failed: \(error.localizedDescription, privacy: .public)") - store.clearDictationPreview() - store.reportDictationFailure("Couldn't finish recording: \(error.localizedDescription)") - } - } - } - - private func handleEngineError(message: String) { - dictationLog.error("composer dictation engine error: \(message, privacy: .public)") - guard let store else { return } - guard transitioning || store.dictationPhase == .preparing || store.dictationPhase == .recording - else { return } - - transitioning = false - listener = nil - _ = hotkeys.setAgentCaptureActive(active: false) - // Never abandon a live recorder: dropping UI state without stopping the - // engine leaves the microphone captured behind an Idle tray, and the next - // toggle — reading the reset UI — starts a second, orphaned capture - // (2026-08-12 incident). Release the engine before reporting. - Task { @MainActor in - _ = try? await dictation.stopRecording() - } - store.reportDictationFailure("Dictation stopped: \(message)") - } - - /// Check (and, if undetermined, request) microphone access. The request wrapper - /// blocks on the system prompt, so it runs off the main actor. - private static func ensureMicPermission() async -> Bool { - if micPermissionGranted() { return true } - return await Task.detached { requestMicPermission() }.value - } -} - -/// Foreign dictation listener for the composer path. Preview callbacks carry the -/// current uncommitted snapshot, while `onFinal` commits an utterance to the -/// listener's live display buffer. The composer still reads the authoritative -/// final transcript from `stopRecording()` before mutating the draft. -final class ComposerDictationListener: CsTranscriptionListener, @unchecked Sendable { - private weak var store: AgentChatStore? - private let onError: (String) -> Void - private let lock = NSLock() - private var committedSegments: [(utteranceId: UInt64, text: String)] = [] - private var activePreview = "" - - init(store: AgentChatStore, onError: @escaping (String) -> Void) { - self.store = store - self.onError = onError - } - - func onRecordingPreparing() {} - func onRecordingStarted() {} - func onRecordingStopped() {} - func onRecordingFinalising() {} - func onPreview(text: String) { - publishPreview { - activePreview = text - } - } - func onCorrection(text: String, previousText: String) {} - func onFinal( - utteranceId: UInt64, text: String, avgLogprob: Float?, speechPct: Float?, - confidenceFlags: [String] - ) { - publishPreview { - activePreview = "" - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - if let index = committedSegments.firstIndex(where: { $0.utteranceId == utteranceId }) { - committedSegments[index].text = trimmed - } else { - committedSegments.append((utteranceId: utteranceId, text: trimmed)) + "Agent voice capture gesture failed: \(error.localizedDescription, privacy: .public)") + store.reportDictationFailure("Couldn't change recording: \(error.localizedDescription)") } } } - /// Layered bounded patch (Layer 1 tail-patch, Layer 2 lexicon/LLM). The - /// composer assembles its own live preview from `onFinal`, so a listener - /// that ignored these would keep showing text the engine has already - /// retracted — split-brain against the overlay, which does apply them. - /// `start`/`end` are Rust-canonical char offsets inside `utteranceId`; - /// unbound or overrunning windows are dropped whole rather than - /// half-applied, matching the overlay and the Rust committed buffer. - /// - /// `lastIndex` is not a style choice — it is the third copy of one algebra. - /// The same patch is resolved by `live_assembly.rs` (`rposition`) and by - /// `OverlayState.onReplaceRange` (`lastIndex`); this used to say - /// `firstIndex`, which picks the opposite slot the moment an utterance id is - /// ever sealed twice. Today it cannot be (`EngineEvent::UtteranceFinal` is - /// contracted "once per VAD-bounded segment"), so the divergence was latent - /// and invisible — see - /// `re_sealed_utterance_id_duplicates_here_but_not_in_the_swift_surfaces`, - /// which pins how the three surfaces disagree. Changing this one word costs - /// nothing today and removes one of the three ways they can drift apart. - func onReplaceRange( - utteranceId: UInt64, start: UInt64, end: UInt64, text: String, source: CsLayerSource - ) { - publishPreview { - guard let index = committedSegments.lastIndex(where: { $0.utteranceId == utteranceId }), - let startOffset = Int(exactly: start), - let endOffset = Int(exactly: end), - startOffset <= endOffset - else { return } - var patched = committedSegments[index].text - guard endOffset <= patched.count else { return } - let lower = patched.index(patched.startIndex, offsetBy: startOffset) - let upper = patched.index(patched.startIndex, offsetBy: endOffset) - patched.replaceSubrange(lower.. Void) { - lock.lock() - update() - let snapshot = mergedPreviewLocked() - lock.unlock() - Task { @MainActor [weak store] in - store?.updateDictationPreview(snapshot) - } - } - - private func publishFinalPreview(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - Task { @MainActor [weak store] in - store?.noteDictationFinalPreview(trimmed) - } - } - - private func mergedPreviewLocked() -> String { - var parts = committedSegments.map(\.text) - let active = activePreview.trimmingCharacters(in: .whitespacesAndNewlines) - if !active.isEmpty { - parts.append(active) - } - return parts.joined(separator: " ") - } } diff --git a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift index 20c79502..3149a308 100644 --- a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift +++ b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift @@ -498,10 +498,9 @@ protocol ChatThreadsProviding: AnyObject { func generateThreadId() -> String } -// MARK: - Composer dictation seam (voice message → transcript into the draft) +// MARK: - Composer gesture seam (Agent capture on the shared controller) -/// Lifecycle of the composer's own voice-note dictation. Independent from the -/// hotkey / overlay dictation session — this drives only the composer mic. +/// Agent-facing view of the shared controller lifecycle. enum ComposerDictationPhase: Equatable { case idle case preparing // permission / model load / start-stop transition in flight @@ -509,35 +508,11 @@ enum ComposerDictationPhase: Equatable { case failed(String) } -enum ComposerCaptureCommand { - case startAssistive - case stopAssistive - case toggleAssistive -} - -enum DictationDeliverySource: Equatable { - case live - case final - case edited - - var label: String { - switch self { - case .live: return "live chosen" - case .final: return "final chosen" - case .edited: return "edited text chosen" - } - } -} - -/// UI-only seam over the composer dictation controller. The real adapter -/// (`RealComposerDictation`, Core layer) wraps the `CodescribeDictation` bridge; -/// kept bridge-free here so the view-model + #Preview stay standalone (nil = mic -/// is a no-op, e.g. in previews). +/// UI-only gesture seam over the shared recording controller. @MainActor protocol ComposerDictating: AnyObject { - /// Start recording when idle, stop-and-insert when recording. + /// Start Agent capture when idle, stop-and-send when recording. func toggle() - func handle(_ command: ComposerCaptureCommand) } // MARK: - Store @@ -555,14 +530,6 @@ final class AgentChatStore: ObservableObject { /// Monotonic UI command consumed by the composer. It carries no text and /// deliberately does not mutate the selected thread or staged attachments. @Published private(set) var composerFocusRequest: UInt64 = 0 - @Published private(set) var dictationPreview: String = "" - @Published private(set) var dictationLivePreview: String = "" - @Published private(set) var dictationFinalPreview: String? - @Published private(set) var dictationFinalChangedText = false - @Published private(set) var dictationVadActive = false - @Published private(set) var dictationPreviewUserEdited = false - @Published private(set) var dictationDeliverySource: DictationDeliverySource = .live - /// Images staged in the composer for the next message. Cleared when the /// message is dispatched. @Published var pendingAttachments: [PendingAttachment] = [] @@ -574,9 +541,7 @@ final class AgentChatStore: ObservableObject { /// affordance (ripple while `.recording`) and the inline error feedback. @Published private(set) var dictationPhase: ComposerDictationPhase = .idle - /// True while a hotkey / tray / overlay dictation session owns the microphone. - /// Set from the authoritative recording lifecycle hooks (see OverlayController) - /// so the composer mic can't open a second, colliding recorder. + /// True while the one shared controller owns the microphone. @Published var dictationBlocked: Bool = false /// Injected real adapter (Core). `nil` in previews / mock → mic is inert. @@ -586,13 +551,9 @@ final class AgentChatStore: ObservableObject { /// a newer state. private var dictationFailureToken = UUID() - /// Toggle the composer voice note (start ↔ stop-and-insert). + /// Toggle Agent capture (start ↔ stop-and-send). func toggleDictation() { dictation?.toggle() } - func handleAssistiveCapture(_ command: ComposerCaptureCommand) { - dictation?.handle(command) - } - func requestComposerFocus() { composerFocusRequest &+= 1 } @@ -601,97 +562,10 @@ final class AgentChatStore: ObservableObject { /// when no adapter is wired. func setDictationPhase(_ phase: ComposerDictationPhase) { dictationPhase = phase } - /// Latest live voice-note preview. This is a snapshot buffer from the STT - /// listener, not a delta stream, and stays separate from `draft` until stop. - func beginDictationPreviewSession() { - dictationPreview = "" - dictationLivePreview = "" - dictationFinalPreview = nil - dictationFinalChangedText = false - dictationVadActive = false - dictationPreviewUserEdited = false - dictationDeliverySource = .live - } - - /// Idempotent on purpose. Apple live polls partials every ~40 ms, so during a - /// pause the SAME text arrives ~25×/s. Publishing an unchanged value still - /// fires `objectWillChange`, rebuilding the whole Agent window body — and the - /// preview's `TextEditor` is NSTextView-backed, so each rebuild mutates the - /// AppKit subtree, invalidates the window's structural regions and re-runs the - /// deep `cursorUpdate:` walk (measured 2026-08-05: ~30% of main-thread samples - /// in `setCursorForMouseLocation:` → `NSCursor _reallySet`, visible as the - /// pointer flickering between I-beam and arrow, plus a PDF cursor-image reload - /// per frame). Writing only on real change removes the whole storm at the - /// source; see `plans/gtm-closure-260804/evidence/2026-08-05_cursor-storm-sample.txt`. - func updateDictationPreview(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if dictationLivePreview != trimmed { dictationLivePreview = trimmed } - guard !dictationPreviewUserEdited else { return } - if dictationPreview != trimmed { dictationPreview = trimmed } - } - - func editDictationPreview(_ text: String) { - dictationPreview = text - dictationPreviewUserEdited = true - dictationDeliverySource = .edited - } - - func noteDictationFinalPreview(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - let final = trimmed.isEmpty ? nil : trimmed - let changed = !trimmed.isEmpty && trimmed != dictationLivePreview - if dictationFinalPreview != final { dictationFinalPreview = final } - if dictationFinalChangedText != changed { dictationFinalChangedText = changed } - } - - /// Same idempotence contract as `updateDictationPreview`: the VAD callback - /// fires per audio chunk, and republishing an unchanged flag rebuilds the - /// window body for nothing. - func setDictationVadActive(_ active: Bool) { - guard dictationVadActive != active else { return } - dictationVadActive = active - } - - /// Preserve both hypotheses and explicitly choose the delivery text. A - /// materially shorter final pass may fill gaps but must never erase a better - /// live canvas. User edits always win and cancel Assistive auto-send. - func resolveDictationDelivery(final text: String, autoSend: Bool) -> ( - text: String, autoSend: Bool - ) { - let final = text.trimmingCharacters(in: .whitespacesAndNewlines) - dictationFinalPreview = final.isEmpty ? nil : final - dictationFinalChangedText = !final.isEmpty && final != dictationLivePreview - - if dictationPreviewUserEdited { - dictationDeliverySource = .edited - return (dictationPreview.trimmingCharacters(in: .whitespacesAndNewlines), false) - } - - let live = dictationLivePreview.trimmingCharacters(in: .whitespacesAndNewlines) - let liveWords = live.split(whereSeparator: \Character.isWhitespace).count - let finalWords = final.split(whereSeparator: \Character.isWhitespace).count - let finalRegressed = !live.isEmpty && (final.isEmpty || finalWords * 100 < liveWords * 85) - let chosen = finalRegressed ? live : final - dictationDeliverySource = finalRegressed ? .live : .final - dictationPreview = chosen - return (chosen, autoSend) - } - - func clearDictationPreview() { - dictationPreview = "" - dictationLivePreview = "" - dictationFinalPreview = nil - dictationFinalChangedText = false - dictationVadActive = false - dictationPreviewUserEdited = false - dictationDeliverySource = .live - } - /// Surface a recoverable dictation failure with a self-clearing inline message /// (auto-returns to `.idle` after a few seconds so the composer doesn't keep a /// stale error banner). func reportDictationFailure(_ message: String) { - clearDictationPreview() dictationPhase = .failed(message) let token = UUID() dictationFailureToken = token @@ -702,19 +576,6 @@ final class AgentChatStore: ObservableObject { } } - /// Append the explicitly resolved voice transcript to the editable draft. - /// Preview provenance remains visible until the next capture starts. - func appendDictatedTranscript(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - if draft.isEmpty { - draft = trimmed - } else { - let needsSeparator = !(draft.last?.isWhitespace ?? false) - draft += (needsSeparator ? " " : "") + trimmed - } - } - /// Injected by W2-01. `nil` until then; `send` degrades gracefully. var engine: AgentChatEngine? diff --git a/macos/Codescribe/Screens/AgentChat/Composer.swift b/macos/Codescribe/Screens/AgentChat/Composer.swift index 3c252f3b..6dadeb77 100644 --- a/macos/Codescribe/Screens/AgentChat/Composer.swift +++ b/macos/Codescribe/Screens/AgentChat/Composer.swift @@ -24,7 +24,6 @@ struct Composer: View { @Environment(\.csTextScale) private var textScale @Environment(\.openSettings) private var openSettings @State private var fieldHeight = ComposerTextLayout.minimumHeight(fontSize: 13.5) - @AppStorage("AgentChat.dictationPreviewExpanded.v1") private var dictationPreviewExpanded = false @State private var previewAttachment: PendingAttachment? // ⌘V interception. The native text editor consumes `paste:` before any @@ -136,7 +135,6 @@ struct Composer: View { .overlay(dropCatcher) .animation(.easeOut(duration: 0.12), value: isDragging) - dictationPreview dictationFeedback // Affordance row @@ -340,10 +338,9 @@ struct Composer: View { // MARK: Voice-note mic - /// The composer mic: click to start a voice note, click again to stop and - /// insert the transcript into the draft. The ripple lives only while - /// recording; a spinner shows the preparing transition. Disabled (and dimmed) - /// while a hotkey/overlay dictation session owns the microphone. + /// The composer mic starts/stops the same Agent route as Right Option. The + /// ripple follows the shared controller lifecycle; no composer recorder or + /// editable transcript copy exists. private var micButton: some View { Button(action: { store.toggleDictation() }) { micVisual @@ -363,11 +360,10 @@ struct Composer: View { } private var micState: ComposerMicVisualState { - if store.dictationBlocked { return .blocked } switch store.dictationPhase { case .preparing: return .preparing case .recording: return .recording - case .idle, .failed: return .idle + case .idle, .failed: return store.dictationBlocked ? .blocked : .idle } } @@ -404,91 +400,6 @@ struct Composer: View { } } - @ViewBuilder - private var dictationPreview: some View { - if !store.dictationPreview.isEmpty { - VStack(alignment: .leading, spacing: 7) { - HStack(spacing: 7) { - CSIconView(icon: .mic, size: 10.5, color: CSColor.terracottaLight) - Text(dictationPhaseLabel) - Text(store.dictationVadActive ? "speech" : "silence") - .foregroundStyle(store.dictationVadActive ? CSColor.oliveLight : CSColor.textFaintAlt) - if store.dictationFinalChangedText { - Text("final differs · \(store.dictationDeliverySource.label)") - .foregroundStyle(CSColor.amber) - } - if store.dictationPreviewUserEdited { - Text("edited · auto-send off") - .foregroundStyle(CSColor.chromeAccent) - } - Spacer(minLength: 8) - Button(dictationPreviewExpanded ? "Collapse" : "Expand") { - dictationPreviewExpanded.toggle() - } - .csFocusRing(cornerRadius: 8) - .accessibilityLabel( - dictationPreviewExpanded ? "Collapse transcript preview" : "Expand transcript preview") - } - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - - TextEditor( - text: Binding( - get: { store.dictationPreview }, - set: { store.editDictationPreview($0) } - ) - ) - .font(CSFont.ui(12.5 * textScale)) - .foregroundStyle(CSColor.textBodyAlt) - .scrollContentBackground(.hidden) - .frame( - minHeight: dictationPreviewExpanded ? 150 : 58, - maxHeight: dictationPreviewExpanded ? 260 : 96 - ) - .accessibilityLabel("Live transcript preview") - - if store.dictationFinalChangedText, - let final = store.dictationFinalPreview, - final != store.dictationLivePreview - { - DisclosureGroup( - store.dictationDeliverySource == .final ? "Live capture" : "Final-pass alternative" - ) { - // Bounded on purpose: an unbounded Text with a long - // transcript grew the composer past the window and - // wrecked the split-view layout on expand (operator - // screenshot, 2026-08-09). The alternative scrolls - // inside its own strip instead. - ScrollView { - Text(store.dictationDeliverySource == .final ? store.dictationLivePreview : final) - .font(CSFont.ui(11.5 * textScale)) - .foregroundStyle(CSColor.textFaint) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - } - .frame(maxHeight: 140) - } - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - } - .padding(10) - .background(CSColor.surfaceRaised(0.04)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - private var dictationPhaseLabel: String { - switch store.dictationPhase { - case .idle: return "captured" - case .preparing: return "preparing" - case .recording: return "recording" - case .failed: return "stopped" - } - } - /// Transparent drop target layered over the input box. Hit-testable only /// while a drag is in progress, so it intercepts a field drop (beating the /// native text editor) without blocking clicks/typing at rest. Its own diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 549e18f4..676879f9 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -1542,8 +1542,8 @@ final class SettingsViewModel: ObservableObject { } /// STT is "healthy" (olive dot) when a local model is configured, or when a - /// cloud endpoint is set. We can't probe live Whisper load state from the - /// config engine alone — that lives on `CodescribeDictation` (tracked gap). + /// cloud endpoint is set. Runtime serving truth comes from the shared + /// controller snapshot, not this configuration-only health estimate. var sttHealthy: Bool { settings.useLocalStt ? !settings.localModel.isEmpty diff --git a/macos/Codescribe/Screens/Tray/RealTrayEngine.swift b/macos/Codescribe/Screens/Tray/RealTrayEngine.swift index 9b94a6a8..e6f9ed2c 100644 --- a/macos/Codescribe/Screens/Tray/RealTrayEngine.swift +++ b/macos/Codescribe/Screens/Tray/RealTrayEngine.swift @@ -7,9 +7,8 @@ import Foundation // • CodescribeConfig — quick config toggles (settings.json / .env router). // • CodescribeThreads — most-recent transcript path + text. // -// Dictation deliberately routes through CodescribeHotkeys instead of -// CodescribeDictation so tray + keyboard shortcuts share one RecordingController -// and cannot open two independent overlays/recorders. +// Dictation routes through CodescribeHotkeys so tray, Agent, and keyboard +// shortcuts share one RecordingController and cannot open parallel recorders. final class RealTrayEngine: TrayEngine { private let agent: CodescribeAgent private let hotkeys: CodescribeHotkeys diff --git a/macos/Codescribe/Screens/Tray/TrayEngine.swift b/macos/Codescribe/Screens/Tray/TrayEngine.swift index f7a2afa6..1ec6456b 100644 --- a/macos/Codescribe/Screens/Tray/TrayEngine.swift +++ b/macos/Codescribe/Screens/Tray/TrayEngine.swift @@ -4,7 +4,7 @@ import Foundation // UniFFI bridge. The tray needs three slices of the engine — agent readiness, // dictation control, and a couple of quick config toggles — plus read access to // the most recent transcript. The concrete `RealTrayEngine` (see its own file) -// wraps CodescribeAgent / CodescribeDictation / CodescribeConfig / +// wraps CodescribeAgent / CodescribeHotkeys / CodescribeConfig / // CodescribeThreads; `MockTrayEngine` keeps `#Preview` self-contained. /// Navigation intents the tray emits. App.swift binds each one to the action diff --git a/macos/CodescribeTests/AgentSummonTests.swift b/macos/CodescribeTests/AgentSummonTests.swift index cc91320f..4332608e 100644 --- a/macos/CodescribeTests/AgentSummonTests.swift +++ b/macos/CodescribeTests/AgentSummonTests.swift @@ -80,21 +80,6 @@ final class AgentSummonTests: XCTestCase { await fulfillment(of: [delivered], timeout: 1.0) } - func testAssistiveCallbackFrontsExistingAgentAndRoutesCaptureCommand() async { - let delivered = expectation(description: "Agent capture action") - let listener = AgentAppActionListener( - summonAgent: {}, - captureAgent: { command in - XCTAssertEqual(command, .toggle) - delivered.fulfill() - } - ) - - listener.onAgentCapture(command: .toggle) - - await fulfillment(of: [delivered], timeout: 1.0) - } - func testAgentPinMapsToFloatingAndNormalWindowLevels() { XCTAssertEqual( AgentWindowLevelPolicy.level(isPinned: true).rawValue, diff --git a/macos/CodescribeTests/ComposerMicTests.swift b/macos/CodescribeTests/ComposerMicTests.swift index 35c031d0..c8de50b0 100644 --- a/macos/CodescribeTests/ComposerMicTests.swift +++ b/macos/CodescribeTests/ComposerMicTests.swift @@ -1,11 +1,7 @@ -import Combine import XCTest @testable import Codescribe -// Executed by `make test-swift`. The composer ReplaceRange assertions below run -// for real — 11 tests in this file, ~0.01 s. See CodescribeTests/README.md. - @MainActor final class ComposerMicTests: XCTestCase { func testEveryStateKeepsTheMicrophoneGlyph() { @@ -34,158 +30,6 @@ final class ComposerMicTests: XCTestCase { XCTAssertFalse(ComposerMicVisualState.blocked.isEnabled) } - func testFinalPassRegressionKeepsLongerLiveTranscriptAndPreservesAlternative() { - let store = AgentChatStore() - store.beginDictationPreviewSession() - store.updateDictationPreview("one two three four five six seven eight nine ten") - - let result = store.resolveDictationDelivery(final: "one two three", autoSend: true) - - XCTAssertEqual(result.text, "one two three four five six seven eight nine ten") - XCTAssertTrue(result.autoSend) - XCTAssertEqual(store.dictationFinalPreview, "one two three") - XCTAssertTrue(store.dictationFinalChangedText) - XCTAssertEqual(store.dictationDeliverySource, .live) - } - - func testUserEditedPreviewWinsAndCancelsAssistiveAutoSend() { - let store = AgentChatStore() - store.beginDictationPreviewSession() - store.updateDictationPreview("live machine text") - store.editDictationPreview("human owned text") - store.updateDictationPreview("later callback must not overwrite the edit") - - let result = store.resolveDictationDelivery(final: "final machine text", autoSend: true) - - XCTAssertEqual(result.text, "human owned text") - XCTAssertFalse(result.autoSend) - XCTAssertTrue(store.dictationPreviewUserEdited) - XCTAssertEqual(store.dictationDeliverySource, .edited) - } - - /// Guards the cursor-storm regression (2026-08-05): Apple live re-delivers the - /// SAME partial ~25×/s during a pause. Each republish rebuilt the Agent window - /// body and re-ran AppKit's deep `cursorUpdate:` walk, burning ~30% of the main - /// thread and flickering the pointer between I-beam and arrow. Unchanged input - /// must therefore emit ZERO `objectWillChange`. - func testRepeatedIdenticalPartialsPublishNoChange() { - let store = AgentChatStore() - store.beginDictationPreviewSession() - - var publishes = 0 - let token = store.objectWillChange.sink { _ in publishes += 1 } - defer { token.cancel() } - - // Assert on DELTAS, not absolute counts: one partial legitimately touches - // more than one @Published property (live buffer + preview buffer). - store.updateDictationPreview("mamy licencję") - let afterFirst = publishes - XCTAssertGreaterThan(afterFirst, 0, "first partial must publish") - - for _ in 0..<25 { store.updateDictationPreview("mamy licencję") } - XCTAssertEqual(publishes, afterFirst, "25 identical partials must publish nothing") - - store.updateDictationPreview("mamy licencję i fajny format") - XCTAssertGreaterThan(publishes, afterFirst, "a changed partial must publish") - - let afterChange = publishes - for _ in 0..<25 { store.updateDictationPreview("mamy licencję i fajny format") } - XCTAssertEqual(publishes, afterChange, "repeats of the new text must publish nothing") - } - - func testRepeatedVadFlagsPublishNoChange() { - let store = AgentChatStore() - store.beginDictationPreviewSession() - - var publishes = 0 - let token = store.objectWillChange.sink { _ in publishes += 1 } - defer { token.cancel() } - - for _ in 0..<10 { store.setDictationVadActive(false) } - XCTAssertEqual(publishes, 0, "unchanged VAD flag must not republish") - - store.setDictationVadActive(true) - XCTAssertEqual(publishes, 1, "VAD transition must publish once") - } - - // ── Composer × layered transcription ──────────────────────────────────── - // - // The composer's live preview is assembled Swift-side by - // `ComposerDictationListener` from `onFinal`/`onPreview`. Layer 1 corrects - // sealed utterances afterwards via `onReplaceRange`, so a listener that - // ignores those events shows the user text the engine has already - // retracted — split-brain against the overlay, which does apply them. - - /// Drain queued main-actor hops until the store settles on `expected`. - /// `publishPreview` hands off through `Task { @MainActor … }`, so the - /// update is one scheduling hop away from the call that caused it. - private func awaitDictationPreview( - _ store: AgentChatStore, - equals expected: String, - file: StaticString = #filePath, - line: UInt = #line - ) async { - for _ in 0..<500 { - if store.dictationPreview == expected { return } - await Task.yield() - } - XCTAssertEqual(store.dictationPreview, expected, file: file, line: line) - } - - func testTailPatchCorrectsTheComposerLivePreviewInPlace() async { - let store = AgentChatStore() - store.beginDictationPreviewSession() - let listener = ComposerDictationListener(store: store, onError: { _ in }) - - listener.onFinal( - utteranceId: 1, text: "korzystając z Tulczajn 2024", - avgLogprob: nil, speechPct: nil, confidenceFlags: [] - ) - await awaitDictationPreview(store, equals: "korzystając z Tulczajn 2024") - - listener.onReplaceRange( - utteranceId: 1, start: 14, end: 22, text: "Toolchain", source: .tailPatch - ) - await awaitDictationPreview(store, equals: "korzystając z Toolchain 2024") - } - - func testComposerTailPatchTargetsOnlyItsOwnUtterance() async { - let store = AgentChatStore() - store.beginDictationPreviewSession() - let listener = ComposerDictationListener(store: store, onError: { _ in }) - - listener.onFinal( - utteranceId: 1, text: "pierwsze zdanie", - avgLogprob: nil, speechPct: nil, confidenceFlags: [] - ) - listener.onFinal( - utteranceId: 2, text: "drugie zdanie", - avgLogprob: nil, speechPct: nil, confidenceFlags: [] - ) - listener.onReplaceRange( - utteranceId: 2, start: 0, end: 6, text: "trzecie", source: .tailPatch - ) - - await awaitDictationPreview(store, equals: "pierwsze zdanie trzecie zdanie") - } - - /// An unbound or overrunning window is dropped, never half-applied — the - /// same rule the overlay and the Rust committed buffer follow. - func testComposerDropsUnboundOrOutOfRangePatches() async { - let store = AgentChatStore() - store.beginDictationPreviewSession() - let listener = ComposerDictationListener(store: store, onError: { _ in }) - - listener.onFinal( - utteranceId: 1, text: "krótkie", - avgLogprob: nil, speechPct: nil, confidenceFlags: [] - ) - listener.onReplaceRange(utteranceId: 9, start: 0, end: 3, text: "X", source: .tailPatch) - listener.onReplaceRange(utteranceId: 1, start: 0, end: 999, text: "X", source: .tailPatch) - - await awaitDictationPreview(store, equals: "krótkie") - } - func testPendingAttachmentPreviewUsesExactStagedURL() { let url = URL(fileURLWithPath: "/tmp/exact-staged-preview.png") let pending = PendingAttachment(url: url) From a1f71fa59f9a951fe46c569832014612ff31464f Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 10:16:39 +0200 Subject: [PATCH 3/8] [codex/vc-workflow] fix: bind agent voice capture to one thread --- Makefile | 6 + macos/Codescribe/Core/AppModel.swift | 23 +- macos/Codescribe/Core/ComposerDictation.swift | 17 +- .../Screens/AgentChat/AgentChatStore.swift | 97 +++++++- .../Screens/AgentChat/Composer.swift | 5 + .../Screens/Tray/TrayViewModel.swift | 21 +- .../AgentThreadContinuityTests.swift | 123 ++++++++++ .../AgentVoiceLaneOwnershipTests.swift | 221 ++++++++++++++++++ scripts/validate-gates.sh | 3 + 9 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 macos/CodescribeTests/AgentVoiceLaneOwnershipTests.swift diff --git a/Makefile b/Makefile index 1fcfcf55..65fb97a7 100644 --- a/Makefile +++ b/Makefile @@ -881,6 +881,12 @@ test-swift: $(ENGINE_BRIDGE) echo "test-swift: run 'make app-bindings' (or 'make app') first." >&2; \ exit 2; \ fi; \ + if ! command -v xcodegen >/dev/null 2>&1; then \ + echo "test-swift: xcodegen is required because the Xcode project is generated, not committed." >&2; \ + exit 2; \ + fi; \ + echo "=== Regenerating Xcode project from project.yml ==="; \ + ( cd macos && xcodegen generate ) || exit $$?; \ echo "=== Swift front-end tests (CodescribeTests) ==="; \ cd macos && xcodebuild test \ -scheme Codescribe \ diff --git a/macos/Codescribe/Core/AppModel.swift b/macos/Codescribe/Core/AppModel.swift index 1e16ba68..61788c2d 100644 --- a/macos/Codescribe/Core/AppModel.swift +++ b/macos/Codescribe/Core/AppModel.swift @@ -109,14 +109,20 @@ final class OverlayController: ObservableObject { // overlay already receives. The tray view-model otherwise only polls on // appear (and the popover is built once), so it stayed "Recording" after // Finish. These hooks fire for every start/stop path (hotkey, tray, auto). + // The composer's phase is DERIVED from the lane the session turned out to be + // (`sessionWasAssistive`) — never left to whatever the optimistic gesture set. + // `.preparing` and `.recording` are both non-actionable in the composer, so a + // phase that is entered optimistically and only cleared on a latch that read + // true is a mic that can die permanently. Non-assistive sessions therefore + // push the composer back to `.idle` (it renders as `.blocked` off + // `dictationBlocked`, which is the honest "busy elsewhere" state), and every + // terminal beat resets unconditionally. state.onRecordingPreparing = { [weak self] in guard let self else { return } self.sessionWasAssistive = false self.refreshAssistiveLatch() self.showForRecording() - if self.sessionWasAssistive { - AppModel.shared.chat.setDictationPhase(.preparing) - } + AppModel.shared.chat.setDictationPhase(self.sessionWasAssistive ? .preparing : .idle) AppModel.shared.tray.isStartingDictation = true // Block the composer mic while the shared recorder owns the microphone. AppModel.shared.chat.dictationBlocked = true @@ -125,9 +131,7 @@ final class OverlayController: ObservableObject { guard let self else { return } self.refreshAssistiveLatch() self.showForRecording() - if self.sessionWasAssistive { - AppModel.shared.chat.setDictationPhase(.recording) - } + AppModel.shared.chat.setDictationPhase(self.sessionWasAssistive ? .recording : .idle) AppModel.shared.tray.isRecording = true AppModel.shared.tray.isStartingDictation = false AppModel.shared.chat.dictationBlocked = true @@ -135,13 +139,12 @@ final class OverlayController: ObservableObject { state.onRecordingStopped = { [weak self] in guard let self else { return } self.refreshAssistiveLatch() - if self.sessionWasAssistive { - AppModel.shared.chat.setDictationPhase(.idle) - } self.markStopped() AppModel.shared.tray.isRecording = false AppModel.shared.tray.isStartingDictation = false - AppModel.shared.chat.dictationBlocked = false + // Unconditional: releases the composer phase, the blocked flag and the + // thread-ownership latch in one beat, whatever the lane turned out to be. + AppModel.shared.chat.endDictationSession() } state.onSuccessfulDictation = { Task { @MainActor in diff --git a/macos/Codescribe/Core/ComposerDictation.swift b/macos/Codescribe/Core/ComposerDictation.swift index 61d1a0e8..a8bd3a92 100644 --- a/macos/Codescribe/Core/ComposerDictation.swift +++ b/macos/Codescribe/Core/ComposerDictation.swift @@ -23,11 +23,19 @@ final class RealComposerDictation: ComposerDictating { func toggle() { guard let store, !transitioning else { return } transitioning = true + // Optimistic beat at click latency; both start and stop are non-actionable + // while in flight, so this also swallows the double-tap. store.setDictationPhase(.preparing) Task { @MainActor in defer { transitioning = false } + // Direction comes from the controller, not from the cached `dictationBlocked` + // flag. A flag left stale by a lifecycle event that never arrived used to + // route every press into a stop that no-ops against an idle controller — + // a mic that looks busy forever with no way back short of a relaunch. + let live = await hotkeys.isRecording() + store.dictationBlocked = live do { - if store.dictationBlocked { + if live { try await hotkeys.stopRecording() dictationLog.info("Agent voice capture stop requested on shared controller") } else { @@ -38,6 +46,13 @@ final class RealComposerDictation: ComposerDictating { dictationLog.error( "Agent voice capture gesture failed: \(error.localizedDescription, privacy: .public)") store.reportDictationFailure("Couldn't change recording: \(error.localizedDescription)") + return + } + // Terminal reconcile against the controller. The lifecycle hooks own the + // happy path; this only catches a gesture that left the controller idle + // without ever broadcasting a terminal event. + if await hotkeys.isRecording() == false { + store.endDictationSession() } } } diff --git a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift index 3149a308..d39c4962 100644 --- a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift +++ b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift @@ -67,6 +67,11 @@ protocol AgentChatEngine: AnyObject { /// "cancelled" turn. @discardableResult func cancelReply(threadId: String) -> Bool + /// Publish the rail's current logical selection to the shared assistive + /// controller. This must be a protocol requirement: calls through the + /// `AgentChatEngine` existential otherwise statically dispatch to the + /// extension's preview no-op instead of `RealChatEngine`. + func setAssistiveTargetThread(backendId: String?) func installToolApprovalHandler( _ handler: @escaping @MainActor (PendingToolApproval) -> Void ) @@ -544,6 +549,24 @@ final class AgentChatStore: ObservableObject { /// True while the one shared controller owns the microphone. @Published var dictationBlocked: Bool = false + /// The thread that owned the rail when the live capture started. Latched on the + /// first non-idle phase and released on every terminal one. + /// + /// Without this latch every voice-lane fact (mic ripple, routing target) was + /// read live and globally, so switching threads mid-capture painted a "ghost" + /// recording mic on a thread that was not receiving the dictation AND re-routed + /// the in-flight transcript to it — the card then landed somewhere the user was + /// no longer looking. One shared recorder ⇒ one owning thread, decided at the + /// gesture and not re-decided until the session ends. + @Published private(set) var dictationThreadID: UUID? + + /// True when the rail selection is the thread that owns the live capture (or + /// no capture is live). The composer only shows preparing/recording affordances + /// for the owning thread; every other thread sees the mic as busy. + var dictationOwnsSelectedThread: Bool { + dictationThreadID == nil || dictationThreadID == selectedThreadID + } + /// Injected real adapter (Core). `nil` in previews / mock → mic is inert. var dictation: ComposerDictating? @@ -560,19 +583,53 @@ final class AgentChatStore: ObservableObject { /// Set by the real adapter as the dictation session transitions. No-op-safe /// when no adapter is wired. - func setDictationPhase(_ phase: ComposerDictationPhase) { dictationPhase = phase } + /// + /// This is the single choke point every lifecycle path funnels through + /// (composer gesture, hotkey, tray, orphan compensator), so the ownership latch + /// lives here rather than at any one caller. + func setDictationPhase(_ phase: ComposerDictationPhase) { + switch phase { + case .preparing, .recording: + if dictationThreadID == nil { dictationThreadID = selectedThreadID } + case .idle, .failed: + let hadSession = dictationThreadID != nil + dictationThreadID = nil + // Selection changes were suppressed while the capture was latched — resync + // the routing target to whatever the rail shows now that it is free again. + if hadSession { publishAssistiveTarget(force: true) } + } + dictationPhase = phase + } + + /// Terminal lifecycle beat for the shared recorder: the microphone is free. + /// + /// Unconditional by design. The previous wiring only reset the composer when an + /// assistive latch read true at stop time, so a single missed latch read left + /// the mic pinned in `.preparing`/`.recording` — both non-actionable states — + /// with no recovery short of an app restart. A terminal event must always be + /// able to return the surface to rest. A `.failed` banner is kept so its own + /// self-clearing timer can run out. + func endDictationSession() { + dictationBlocked = false + if case .failed = dictationPhase { + dictationThreadID = nil + publishAssistiveTarget(force: true) + return + } + setDictationPhase(.idle) + } /// Surface a recoverable dictation failure with a self-clearing inline message /// (auto-returns to `.idle` after a few seconds so the composer doesn't keep a /// stale error banner). func reportDictationFailure(_ message: String) { - dictationPhase = .failed(message) + setDictationPhase(.failed(message)) let token = UUID() dictationFailureToken = token Task { @MainActor in try? await Task.sleep(nanoseconds: 4_000_000_000) guard dictationFailureToken == token, case .failed = dictationPhase else { return } - dictationPhase = .idle + setDictationPhase(.idle) } } @@ -775,7 +832,15 @@ final class AgentChatStore: ObservableObject { /// target. A selection without a backend id (freshly minted "+ New thread") /// publishes `nil`, which the controller reads as "mint a fresh thread on /// the next assistive turn". - private func publishAssistiveTarget() { + /// + /// Selection-driven publishes are frozen while a capture session is latched: + /// the target belongs to the thread the user was in when they pressed the mic, + /// and browsing the rail mid-sentence must not steal the in-flight transcript. + /// `force` is for identity transitions that are not the user moving away — + /// binding a draft to a freshly minted backend id, and the resync when the + /// session ends. + private func publishAssistiveTarget(force: Bool = false) { + guard force || dictationThreadID == nil else { return } engine?.setAssistiveTargetThread(backendId: currentThread?.backendId) } @@ -1652,6 +1717,10 @@ final class AgentChatStore: ObservableObject { threadID = thread.id isFirstExchange = true } + // Binding a selected empty draft does not mutate `selectedThreadID`, so its + // didSet cannot publish the new backend. Close that identity transition now + // before any later capture or external refresh can observe a stale nil target. + if threadID == selectedThreadID { publishAssistiveTarget(force: true) } // A skeleton turn can carry context with an empty instruction (e.g. a // clipped dictation) — the bubble still renders for the chip. if !userTurn.text.isEmpty || userTurn.wireText != nil { @@ -2342,10 +2411,21 @@ final class AgentChatStore: ObservableObject { } if keepLocalDrafts { - let locals = threads.filter { thread in - thread.backendId == nil && (thread.id == previousSelectedID || !thread.messages.isEmpty) + let retained = threads.filter { thread in + let isSelected = thread.id == previousSelectedID + let isPopulatedDraft = thread.backendId == nil && !thread.messages.isEmpty + guard isSelected || isPopulatedDraft else { return false } + if let backendId = thread.backendId { + return !next.contains(where: { $0.backendId == backendId }) + } + return !next.contains(where: { $0.id == thread.id }) } - next.append(contentsOf: locals) + // Disk/index publication is not transactional with TurnStarted/Done. If + // one refresh snapshot temporarily omits the selected persisted row, + // retain that logical row instead of falling through to `threads.first` + // and silently publishing a different assistive target. A real explicit + // select/new/delete still changes `previousSelectedID` before this path. + next.append(contentsOf: retained) } let resolved = @@ -2362,7 +2442,8 @@ final class AgentChatStore: ObservableObject { threads = resolved // Selection is user-owned. A completion refresh may reorder or replace // rail rows, but it must preserve the thread the user is reading. The - // completed backend is only a fallback when that selection disappeared. + // selected logical row is retained above across transient index gaps; the + // completed backend is only a fallback after an explicit removal path. if let previousSelectedID, threads.contains(where: { $0.id == previousSelectedID }) { selectedThreadID = previousSelectedID } else if let backendId, let match = threads.first(where: { $0.backendId == backendId }) { diff --git a/macos/Codescribe/Screens/AgentChat/Composer.swift b/macos/Codescribe/Screens/AgentChat/Composer.swift index 6dadeb77..27a9ac38 100644 --- a/macos/Codescribe/Screens/AgentChat/Composer.swift +++ b/macos/Codescribe/Screens/AgentChat/Composer.swift @@ -359,7 +359,12 @@ struct Composer: View { .animation(.easeOut(duration: 0.15), value: store.dictationPhase) } + /// One shared recorder ⇒ the live-capture affordances belong to exactly one + /// thread: the one the rail was on when the mic was pressed. Browsing to any + /// other thread mid-sentence shows the honest "busy" mic instead of a ripple + /// for a dictation that is not landing here. private var micState: ComposerMicVisualState { + guard store.dictationOwnsSelectedThread else { return .blocked } switch store.dictationPhase { case .preparing: return .preparing case .recording: return .recording diff --git a/macos/Codescribe/Screens/Tray/TrayViewModel.swift b/macos/Codescribe/Screens/Tray/TrayViewModel.swift index 1c4a3df0..c55dd65d 100644 --- a/macos/Codescribe/Screens/Tray/TrayViewModel.swift +++ b/macos/Codescribe/Screens/Tray/TrayViewModel.swift @@ -123,6 +123,14 @@ final class TrayViewModel: ObservableObject { } /// Pull prompt-free runtime flags from the engine (call on appear). + /// + /// Also the operator's guaranteed un-stick gesture. `isStartingDictation` is + /// written by the shared recording lifecycle (`onRecordingPreparing`), so a + /// session that never broadcast a terminal event pinned the pill on "Starting" + /// AND — because the same flag is this screen's re-entrancy lock — left the + /// dictation row permanently dead. Reconciling it against the controller here + /// means opening the tray always recovers. Guarded on `startsInFlight` so a + /// genuine start still in flight keeps its lock. func refreshStatus() { guard let engine else { return } if let toggles = engine.currentToggles() { @@ -136,10 +144,19 @@ final class TrayViewModel: ObservableObject { } Task { [weak self] in guard let self else { return } - self.isRecording = await engine.isRecording() + let live = await engine.isRecording() + self.isRecording = live + if !live, self.startsInFlight == 0 { + self.isStartingDictation = false + } } } + /// Count of tray-initiated starts still awaiting the controller. Only these + /// own the `isStartingDictation` lock; a lifecycle-set flag with no local start + /// behind it is reconcilable state, not a lock. + private var startsInFlight = 0 + // MARK: - Dictation toggle /// Flip the dictation session, then reconcile against the engine's truth. @@ -158,6 +175,7 @@ final class TrayViewModel: ObservableObject { if !wasRecording { isStartingDictation = true isRecording = true + startsInFlight += 1 onDictationStartRequested() } Task { [weak self] in @@ -171,6 +189,7 @@ final class TrayViewModel: ObservableObject { } catch { // Swallow: the reconcile below reflects the real session state. } + if !wasRecording { self.startsInFlight = max(0, self.startsInFlight - 1) } self.isStartingDictation = false self.isRecording = await engine.isRecording() } diff --git a/macos/CodescribeTests/AgentThreadContinuityTests.swift b/macos/CodescribeTests/AgentThreadContinuityTests.swift index 04599634..4ed2572d 100644 --- a/macos/CodescribeTests/AgentThreadContinuityTests.swift +++ b/macos/CodescribeTests/AgentThreadContinuityTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import XCTest @@ -10,6 +11,27 @@ import XCTest /// `ingestVoiceTurn` and the completion refresh in `replaceThreads`. @MainActor final class AgentThreadContinuityTests: XCTestCase { + private final class SpyRoutingEngine: AgentChatEngine { + private(set) var assistiveTargets: [String?] = [] + + func isAvailable() -> Bool { true } + func availabilityDetail() -> String? { nil } + func generateThreadTitle(_ text: String) async throws -> String? { nil } + func streamReply( + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (String, String) -> Void, + onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void + ) async throws -> String { "" } + func cancelReply(threadId: String) -> Bool { false } + func setAssistiveTargetThread(backendId: String?) { + assistiveTargets.append(backendId) + } + } + private final class StubThreadsProvider: ChatThreadsProviding { var rows: [(id: String, title: String)] @@ -43,6 +65,12 @@ final class AgentThreadContinuityTests: XCTestCase { store.threads.first { $0.backendId == backendID } } + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 2) + } + func testActivationAppendsToCurrentlyOpenMatchingThreadWithoutChangingSelection() { let provider = StubThreadsProvider([ ("t_active", "Active"), @@ -109,6 +137,101 @@ final class AgentThreadContinuityTests: XCTestCase { XCTAssertNotEqual(store.currentThread?.title, "New thread", "adopted draft takes a real title") } + func testCaptureOwnerSurvivesDoneQueuedRefreshAndSummonUntilExplicitSelection() { + let provider = StubThreadsProvider([ + ("t_history", "History"), + ("t_other", "Other"), + ]) + let engine = SpyRoutingEngine() + let store = AgentChatStore(engine: engine, threadsProvider: provider) + XCTAssertEqual(engine.assistiveTargets.compactMap { $0 }.last, "t_history") + + // Starting from an explicit empty thread publishes nil: the controller + // must mint a backend for this selected logical owner. + let targetsBeforeNewThread = engine.assistiveTargets.count + store.newThread() + let captureOwnerID = store.selectedThreadID + XCTAssertGreaterThan(engine.assistiveTargets.count, targetsBeforeNewThread) + XCTAssertNil(engine.assistiveTargets.last!) + + // TurnStarted binds the backend to that same local row and immediately + // republishes the now-stable backend routing target. + store.ingestVoiceTurn(threadId: "t_voice_owner", userText: "keep this thread") + XCTAssertEqual(store.selectedThreadID, captureOwnerID) + XCTAssertEqual(store.currentThread?.backendId, "t_voice_owner") + XCTAssertEqual(engine.assistiveTargets.compactMap { $0 }.last, "t_voice_owner") + + provider.rows = [ + ("t_voice_owner", "Persisted voice turn"), + ("t_other", "Other"), + ("t_history", "History"), + ] + store.ingestVoiceDone() + + // Reproduce the persistence/index seam: a queued bus refresh and the + // didBecomeKey emitted by a later summon both observe a transient index + // snapshot without the just-completed owner row. + provider.rows = [ + ("t_other", "Other"), + ("t_history", "History"), + ] + ThreadsChangeBus.postThreadsChanged() + let summon = AgentSummonAction(store: store) { + NotificationCenter.default.post(name: NSWindow.didBecomeKeyNotification, object: nil) + } + summon.perform() + drainMainQueue() + + XCTAssertEqual(store.selectedThreadID, captureOwnerID) + XCTAssertEqual(store.currentThread?.backendId, "t_voice_owner") + XCTAssertEqual(engine.assistiveTargets.compactMap { $0 }.last, "t_voice_owner") + + // Explicit navigation remains authoritative and is preserved by the same + // later refresh path; continuity must never become a UI pin. + let otherID = thread("t_other", in: store)!.id + store.select(otherID) + provider.rows = [ + ("t_voice_owner", "Persisted voice turn"), + ("t_other", "Other"), + ("t_history", "History"), + ] + ThreadsChangeBus.postThreadsChanged() + drainMainQueue() + + XCTAssertEqual(store.selectedThreadID, otherID) + XCTAssertEqual(store.currentThread?.backendId, "t_other") + XCTAssertEqual(engine.assistiveTargets.compactMap { $0 }.last, "t_other") + } + + func testStoppedCaptureHasNoComposerPreviewCallbackSurfaceToResurrect() throws { + let macosDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // CodescribeTests/ + .deletingLastPathComponent() // macos/ + let sources = [ + "Codescribe/Core/ComposerDictation.swift", + "Codescribe/Screens/AgentChat/AgentChatStore.swift", + "Codescribe/Screens/AgentChat/Composer.swift", + ] + let orphanedPreviewTokens = [ + "ComposerDictationListener", + "CsTranscriptionListener", + "dictationPreview", + "onPreview(", + "onVadState(", + ] + + for relative in sources { + let text = try String( + contentsOf: macosDir.appendingPathComponent(relative), encoding: .utf8) + for token in orphanedPreviewTokens { + XCTAssertFalse( + text.contains(token), + "\(relative) must not retain `\(token)`; a late callback after stop could recreate the orphaned composer preview" + ) + } + } + } + func testVoiceTurnWithUnknownIdStillMintsThreadWhenSelectionIsBound() { let provider = StubThreadsProvider([("t_history", "History")]) let store = AgentChatStore(threadsProvider: provider) diff --git a/macos/CodescribeTests/AgentVoiceLaneOwnershipTests.swift b/macos/CodescribeTests/AgentVoiceLaneOwnershipTests.swift new file mode 100644 index 00000000..9b4a1de9 --- /dev/null +++ b/macos/CodescribeTests/AgentVoiceLaneOwnershipTests.swift @@ -0,0 +1,221 @@ +import Foundation +import XCTest + +@testable import Codescribe + +/// Ownership proof for the Agent voice lane. +/// +/// There is exactly ONE recorder behind Right Option, the tray, and the composer +/// mic. Before this cut every voice-lane fact was read live and globally, so the +/// surface could disagree with the recorder in three ways at once: +/// +/// * a phase entered optimistically (`.preparing`) but only cleared when an +/// assistive latch happened to read true — both `.preparing` and `.recording` +/// are non-actionable, so one missed read left the mic dead until relaunch; +/// * a `dictationBlocked` flag with no terminal owner, which routed every later +/// press into a stop that no-ops against an idle controller; +/// * a routing target republished on every rail selection, so browsing threads +/// mid-sentence moved the in-flight transcript to a thread the user was not +/// dictating into — and painted a ripple mic there to match. +/// +/// The invariant these tests hold: the capture is owned by the thread the rail was +/// on when the gesture fired, ownership is released by every terminal beat, and no +/// terminal beat is conditional. +@MainActor +final class AgentVoiceLaneOwnershipTests: XCTestCase { + private final class SpyRoutingEngine: AgentChatEngine { + private(set) var assistiveTargets: [String?] = [] + + func isAvailable() -> Bool { true } + func availabilityDetail() -> String? { nil } + func generateThreadTitle(_ text: String) async throws -> String? { nil } + func streamReply( + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (String, String) -> Void, + onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void + ) async throws -> String { "" } + func cancelReply(threadId: String) -> Bool { false } + func setAssistiveTargetThread(backendId: String?) { + assistiveTargets.append(backendId) + } + } + + private final class StubThreadsProvider: ChatThreadsProviding { + var rows: [(id: String, title: String)] + + init(_ rows: [(id: String, title: String)]) { + self.rows = rows + } + + func listThreads() -> [ChatThread] { + rows.map { row in + var thread = ChatThread(title: row.title, meta: "now") + thread.backendId = row.id + thread.messagesLoaded = true + return thread + } + } + + func searchThreads(query: String) -> [ChatThread] { listThreads() } + func loadMessages(backendId: String) -> [ChatMessage] { [] } + func deleteThread(backendId: String) -> Bool { true } + func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } + func renameThread(backendId: String, title: String) -> Bool { true } + func setGeneratedTitle(backendId: String, title: String) -> Bool { true } + func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } + func generateThreadId() -> String { "t_generated" } + } + + private struct Fixture { + let store: AgentChatStore + let engine: SpyRoutingEngine + let capturing: UUID + let other: UUID + } + + /// Two persisted threads, rail sitting on the first one. + private func makeFixture() -> Fixture { + let engine = SpyRoutingEngine() + let store = AgentChatStore( + engine: engine, + threadsProvider: StubThreadsProvider([ + ("t_capture", "Capture owner"), + ("t_other", "Other"), + ]) + ) + let capturing = store.threads.first { $0.backendId == "t_capture" }!.id + let other = store.threads.first { $0.backendId == "t_other" }!.id + store.select(capturing) + return Fixture(store: store, engine: engine, capturing: capturing, other: other) + } + + // MARK: Terminal beats are unconditional + + func testTerminalBeatReleasesTheMicWhateverTheLaneTurnedOutToBe() { + let f = makeFixture() + f.store.setDictationPhase(.preparing) + f.store.dictationBlocked = true + + f.store.endDictationSession() + + XCTAssertEqual(f.store.dictationPhase, .idle, "a terminal beat must always reach rest") + XCTAssertFalse(f.store.dictationBlocked, "the microphone is free once the session ends") + XCTAssertNil(f.store.dictationThreadID, "ownership must not outlive the capture") + } + + func testAStoppedSessionCannotLeaveTheMicInANonActionableState() { + let f = makeFixture() + // The exact shape of the historical dead mic: the gesture set `.preparing`, + // the session ended, and nothing ever cleared it. + f.store.setDictationPhase(.preparing) + f.store.endDictationSession() + + // `.preparing` and `.recording` are the two states the composer disables. + XCTAssertFalse( + f.store.dictationPhase == .preparing || f.store.dictationPhase == .recording, + "the mic must be pressable again after a session ends" + ) + } + + func testFailedPhaseKeepsItsBannerButStillReleasesOwnership() { + let f = makeFixture() + f.store.setDictationPhase(.recording) + f.store.reportDictationFailure("boom") + + f.store.endDictationSession() + + guard case .failed(let message) = f.store.dictationPhase else { + return XCTFail("the inline failure must survive its own self-clearing timer") + } + XCTAssertEqual(message, "boom") + XCTAssertFalse(f.store.dictationBlocked) + XCTAssertNil(f.store.dictationThreadID, "a failed session still owns nothing") + } + + // MARK: Thread-switch ghost + + func testBrowsingAnotherThreadMidCaptureNeverStealsTheRoutingTarget() { + let f = makeFixture() + f.store.setDictationPhase(.recording) + let targetAtGesture = f.engine.assistiveTargets.last ?? nil + XCTAssertEqual(targetAtGesture, "t_capture") + + f.store.select(f.other) + + XCTAssertEqual( + f.engine.assistiveTargets.last ?? nil, "t_capture", + "the transcript belongs to the thread the mic was pressed in" + ) + XCTAssertEqual(f.store.dictationThreadID, f.capturing) + } + + func testBrowsingAnotherThreadMidCaptureShowsBusyRatherThanAGhostRipple() { + let f = makeFixture() + f.store.setDictationPhase(.recording) + XCTAssertTrue(f.store.dictationOwnsSelectedThread, "the capturing thread owns the affordance") + + f.store.select(f.other) + + XCTAssertFalse( + f.store.dictationOwnsSelectedThread, + "a thread that is not receiving the dictation must not render a recording mic" + ) + } + + func testSessionEndResyncsTheRoutingTargetToWhereTheRailActuallyIs() { + let f = makeFixture() + f.store.setDictationPhase(.recording) + f.store.select(f.other) + + f.store.endDictationSession() + + XCTAssertEqual( + f.engine.assistiveTargets.last ?? nil, "t_other", + "once the mic is free the next capture follows the rail again" + ) + XCTAssertTrue(f.store.dictationOwnsSelectedThread) + } + + func testIdleSelectionChangesStillPublishNormally() { + let f = makeFixture() + f.store.select(f.other) + XCTAssertEqual(f.engine.assistiveTargets.last ?? nil, "t_other") + f.store.select(f.capturing) + XCTAssertEqual(f.engine.assistiveTargets.last ?? nil, "t_capture") + } + + // MARK: Ownership latch + + func testOwnershipIsLatchedAtTheGestureNotReDecidedPerPhase() { + let f = makeFixture() + f.store.setDictationPhase(.preparing) + f.store.select(f.other) + // A late `.recording` (the controller's started beat) must not re-latch onto + // whatever the rail drifted to while the recorder was warming up. + f.store.setDictationPhase(.recording) + + XCTAssertEqual(f.store.dictationThreadID, f.capturing) + XCTAssertEqual(f.engine.assistiveTargets.last ?? nil, "t_capture") + } + + func testVoiceTurnBindingRepublishesEvenWhileACaptureIsLatched() { + let engine = SpyRoutingEngine() + let store = AgentChatStore( + engine: engine, + threadsProvider: StubThreadsProvider([("t_history", "History")]) + ) + store.newThread() + store.setDictationPhase(.recording) + + // Binding a local draft to a freshly minted backend id is an identity + // transition, not the user moving away — it must cross the freeze. + store.ingestVoiceTurn(threadId: "t_voice_owner", userText: "keep this thread") + + XCTAssertEqual(engine.assistiveTargets.last ?? nil, "t_voice_owner") + XCTAssertEqual(store.currentThread?.backendId, "t_voice_owner") + } +} diff --git a/scripts/validate-gates.sh b/scripts/validate-gates.sh index 3b3d0da2..c93d4a30 100755 --- a/scripts/validate-gates.sh +++ b/scripts/validate-gates.sh @@ -159,6 +159,9 @@ fi if [[ "${swift_recipe%%xcodebuild test*}" != *'$(TEST_DATA_DIR_SETUP)'* ]]; then fail "test-swift must export its isolated data directory before xcodebuild test" fi +if [[ "${swift_recipe%%xcodebuild test*}" != *'xcodegen generate'* ]]; then + fail "test-swift must regenerate the ignored Xcode project so new Swift tests enter XCTest" +fi # --------------------------------------------------------------------------- # Collect: verification targets, ledger rows From cb5b8de577b4e163ad875a92b24b04b6b57bcd78 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 10:26:17 +0200 Subject: [PATCH 4/8] [codex/vc-workflow] fix: make live CLI observe the app transcript bus - preserve the historical transcribe live command without opening a second microphone - stream committed app-owned transcript events to stdout - cover command parsing and append-only output semantics --- bin/codescribe.rs | 160 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 6 deletions(-) diff --git a/bin/codescribe.rs b/bin/codescribe.rs index f7431c13..644d2ee9 100644 --- a/bin/codescribe.rs +++ b/bin/codescribe.rs @@ -19,6 +19,9 @@ //! - `--raw` = the Ctrl-hold contract: literal words, no Light+. //! - `-f/--format` = the AI-formatted lane (same `ai_formatting` call and //! lane config the GUI uses; requires a configured key). +//! - `transcribe live` = follow the app-owned clean transcript bus and flush +//! newly committed utterances to stdout one line at a time. It never opens a +//! second microphone or reconstructs text from UI previews. //! //! Provenance goes to stderr, GUI-truth style, so stdout stays pipeable. //! The old `daemon` mode is gone on purpose: the SwiftUI app owns runtime. @@ -38,12 +41,12 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Transcribe an audio file (wav/mp3/m4a) through the product pipeline + /// Transcribe a file or follow the app-owned live transcript bus Transcribe { - /// Path to the audio file - file: std::path::PathBuf, - /// Language code (e.g. pl, en). Default: auto-detect - #[arg(short, long)] + /// Path to the audio file (omit when using `transcribe live`) + file: Option, + /// File language; live accepts it for compatibility but app settings own capture + #[arg(short, long, global = true)] language: Option, /// Live-canvas view: flush each decoded segment as it lands #[arg(long)] @@ -54,9 +57,17 @@ enum Command { /// AI formatting via the configured formatting lane (same as the GUI) #[arg(short, long)] format: bool, + #[command(subcommand)] + mode: Option, }, } +#[derive(Subcommand)] +enum TranscribeMode { + /// Follow the app's committed transcript bus; Ctrl-C closes the reader + Live, +} + fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { @@ -66,8 +77,114 @@ fn main() -> anyhow::Result<()> { stream, raw, format, - } => transcribe(&file, language.as_deref(), stream, raw, format), + mode, + } => match mode { + Some(TranscribeMode::Live) => { + anyhow::ensure!( + file.is_none() && !stream && !raw && !format, + "`transcribe live` does not accept a file, --stream, --raw, or --format" + ); + transcribe_live(language) + } + None => { + let file = file.ok_or_else(|| { + anyhow::anyhow!("missing (or use `codescribe transcribe live`)") + })?; + transcribe(&file, language.as_deref(), stream, raw, format) + } + }, + } +} + +fn transcribe_live(language: Option) -> anyhow::Result<()> { + use codescribe::presentation::transcript_bus::{CleanTranscriptEvent, transcript_bus_path}; + use std::io::{Read, Seek, SeekFrom, Write as _}; + + let path = transcript_bus_path(); + let mut offset = std::fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + let mut pending = Vec::::new(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + runtime.block_on(async move { + eprintln!("codescribe live: app transcript bus -> committed stdout"); + eprintln!("bus={} start=end stop=Ctrl-C", path.display()); + eprintln!( + "language_hint={} owner=Codescribe.app", + language.as_deref().unwrap_or("auto") + ); + + loop { + tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal?; + eprintln!("codescribe live: stopped"); + return Ok(()); + } + () = tokio::time::sleep(std::time::Duration::from_millis(100)) => {} + } + + let mut file = match std::fs::File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; + let file_len = file.metadata()?.len(); + if file_len < offset { + offset = 0; + pending.clear(); + } + file.seek(SeekFrom::Start(offset))?; + let mut chunk = Vec::new(); + file.read_to_end(&mut chunk)?; + offset = offset.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + pending.extend_from_slice(&chunk); + + while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') { + let line: Vec = pending.drain(..=newline).collect(); + let line = &line[..line.len().saturating_sub(1)]; + if line.is_empty() { + continue; + } + let event: CleanTranscriptEvent = match serde_json::from_slice(line) { + Ok(event) => event, + Err(error) => { + eprintln!("codescribe live: invalid transcript event: {error}"); + continue; + } + }; + if let Some(text) = live_event_text(&event.status, &event.text) { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + writeln!(out, "{text}")?; + out.flush()?; + } else if event.status == "utterance_revised" { + eprintln!( + "codescribe live: revision available session={} utterance={}", + event.session_id, + event + .utterance_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "unknown".to_string()) + ); + } + } + } + }) +} + +/// Plain stdout is intentionally append-only. Revisions remain machine-readable +/// in the canonical NDJSON bus and are announced on stderr without transcript +/// content; consumers that need patch semantics should follow the bus directly. +fn live_event_text<'a>(status: &str, text: &'a str) -> Option<&'a str> { + if status != "utterance_committed" { + return None; } + let text = text.trim(); + (!text.is_empty()).then_some(text) } fn transcribe( @@ -159,3 +276,34 @@ fn transcribe( ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn live_command_is_a_subcommand_not_a_file_named_live() { + let cli = Cli::try_parse_from(["codescribe", "transcribe", "live", "--language", "pl"]) + .expect("live command should parse"); + let Command::Transcribe { + file, + language, + mode, + .. + } = cli.command; + assert!(file.is_none()); + assert_eq!(language.as_deref(), Some("pl")); + assert!(matches!(mode, Some(TranscribeMode::Live))); + } + + #[test] + fn live_plain_text_emits_only_nonempty_commits() { + assert_eq!( + live_event_text("utterance_committed", " instrukcja "), + Some("instrukcja") + ); + assert_eq!(live_event_text("utterance_committed", " "), None); + assert_eq!(live_event_text("utterance_revised", "poprawka"), None); + assert_eq!(live_event_text("session_finalized", "całość"), None); + } +} From 9371f6c09f7e17215bcca7ad086fc29a908c2b30 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 10:26:27 +0200 Subject: [PATCH 5/8] [codex/vc-workflow] docs: align repo contract with the living tree - remove the obsolete worktree and swarm control-plane doctrine - codify the single microphone, transcript reducer, delivery, and thread owners - point agents at executable contracts and honest verification lanes --- AGENTS.md | 365 +++++++++--------------------------------------------- 1 file changed, 61 insertions(+), 304 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc330f11..581bb0ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,304 +1,61 @@ ---- -name: codescribe -title: Codescribe Canonical AGENTS Directive -description: Canonical agent instructions, STT Overlay Doctrine, Peer Bus protocol, and high- -velocity Swarm Entrypoint for Codescribe. -version: 1.0.0 -doctrine: stt-overlay-v1 -architecture: living-tree -entrypoint: - bus: AGENT_BUS.md - loctree: loct - build: scripts/build-app.sh - test: make verify - swarm: AGENTS.md#swarm-orchestration--fast-boot-entrypoint -roles: - - operator - - orchestrator - - worker - - audit ---- - -# AGENTS.md — Codescribe - -## Swarm Orchestration & Fast-Boot Entrypoint ("Na Bucie") - -> **FOR ALL INCOMING AGENT SWARMS & MULTI-AGENT DISPATCHES:** -> Boot instantaneously, synchronize context across the Living Tree, and execute -> without friction or human-relay drag. - -### ⚡ 30-Second Swarm Initialization (Fast Boot Protocol) - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 1. READ PEER SIGNAL │ head -80 AGENT_BUS.md │ -│ 2. STRUCTURAL SIGHT │ loct / loctree-mcp (AST map over text grep) │ -│ 3. OBEY DOCTRINE │ 100% Append + Gap Fill Only + corrections on the fly!│ -│ 4. VERIFY LOCAL RUN │ make verify (parity is a bench, not a gate) │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -#### Swarm Execution Matrix - -| Phase | Swarm Role | Primary Tool / Command | Verification Gate | -| :--- | :--- | :--- | :--- | -| **0. Recon & Sight** | `loctree-scout` | `loct` / `loctree-mcp` | `loct occurrences ` / -`slice` | -| **1. Signal Sync** | `bus-coordinator` | `head -80 AGENT_BUS.md` | Read & append signals; check -`OPERATOR_AWAY` | -| **2. Implementation** | `core-worker` | `cargo check` / Rust core | Small, atomic commits with -Authored-By | -| **3. UniFFI Bridge** | `bridge-worker` | `make app-bindings` | Bridge parity check between Rust -& Swift | -| **4. Verification** | `test-falsifier` | gate: `make verify` · bench: `make test-engine-parity` | Layer 0 only: similarity ≥ -0.90 & structural bounds green. Needs the private corpus + loopback — see "Which ruler gates which -lane"; the layered lane is judged on structure, never on Apple fidelity | -| **5. App Build** | `release-builder` | `scripts/build-app.sh` | Developer ID signed binary -verification | - -### 🛡️ Swarm Autonomy & Operational Laws - -1. **Zero Human Relay**: Swarm agents communicate directly via `AGENT_BUS.md`. Never make the -human relay messages between workers. -2. **Living Tree Awareness**: Re-read files before editing. Never revert peer agent work. Work -concurrently in small, coherent commits (`[/]`). -3. **No Blind Surgery**: Structural questions MUST go through Loctree (`loct` / `loctree-mcp`). -Grep is strictly for literal text searching. -4. **Immutable Live Transcript**: Any attempt to rewrite or replace live STT text with Whisper or -post-processing is a hard doctrine violation. -5. **Coalesce AppKit notification observers**: macOS 27 fires AppKit notifications from *inside* -window operations — a popover close reaches `becomeKeyWindow` and storms every `object: nil` -observer in the app (2026-08-07: main thread pinned 93/93 inside `_NSPopoverCloseAndAnimate`, -fixed in `d79781b1`). Any new `NotificationCenter` observer on an AppKit notification must -either coalesce onto the next main-queue tick (pattern: `scheduleExternalThreadsRefresh` in -`AgentChatStore.swift`) or bind to one specific `object:` with an O(1) handler. No disk, no -`DateFormatter`/ICU, no layout inside the callout. The live census is pinned in -`scripts/smoke/appkit-observers.allow` and enforced by `scripts/smoke-macos27.sh` — a new -observer fails the smoke until its discipline is written down. - -### 🩺 Host smoke after every OS / Xcode bump - -`scripts/smoke-macos27.sh` — the standing answer to "did AppKit/CoreGraphics move under us?". -Runs headless and raises no TCC dialog: CoreGraphics constant table vs the raw values pinned in -`app/os/hotkeys/platform.rs`, NSPanel placement clamp, event-tap re-arm, responsibility-disclaim -symbol, Sparkle wiring, AppKit observer census. Rows that need a human at the keyboard are -reported `SKIP`, never silently passed. `--out FILE` writes the filled checklist. - ---- - -## Peer Bus (Do Not Make the Human Relay) - -Read and append: `AGENT_BUS.md` -Cross-agent signals live there (operator away, stalls canceled, peer wake-ups). -At session start: `head -80 AGENT_BUS.md`. If you need another agent, write a `SIGNAL` block — the -operator's orchestration tooling handles peer wake-ups. - -## Agent-Agnostic Worktrees and Evidence Planes - -All agents and dispatchers use the same Vibecrafted-owned geometry. Never encode a client, -vendor, model, or agent name in infrastructure paths (`.claude`, `.codex`, `.gemini`, and similar -roots are forbidden for new worktree infrastructure). - -The three canonical planes are separate: - -- linked checkout: `~/.vibecrafted/worktrees///YYYY_MMDD/` -- durable artifacts: `~/.vibecrafted/artifacts///YYYY_MMDD/{plans,reports,...}` -- ephemeral current-run state: `~/.vibecrafted/control_plane/...` - -Worktree branches use `cut/`. The dispatcher owns canonical ``, ``, date, cut, -artifact-root, and run-root resolution; workers consume those resolved values and must not invent -their own vendor-specific paths. A linked checkout is disposable and must contain no sole copy of -a report, plan, handoff, verifier result, or delivery proof. Durable evidence goes to the artifact -plane. Heartbeats, locks, process metadata, transcripts, and other live supervision state go to -the control-plane runtime and may be collected or removed according to its lifecycle. - -Every linked checkout owns its own ignored `target/` directory. Rust commands in a worker set -`CARGO_TARGET_DIR=$PWD/target` (or use the equivalent checkout-local default) and must never point -at the main checkout, another cut, or a shared fleet target. Sharing Cargo artifacts across -concurrent worktrees can execute a binary compiled from another cut even when the current source -tree differs. Integrators alone use the main checkout target, and integrator gates run with one -writer. Cold compilation is part of trustworthy parallel isolation, not a reason to share target -state. - -Do not create a repository-local `./.vibecrafted` as a competing fourth plane. Existing ignored -repo-local scratch is legacy-only and is not authoritative. Concurrent writers must never -overwrite the same artifact: assign one writer per manifest/report, namespace outputs by run or -cut ID, publish completed files atomically, and use append-only logs only where their format -explicitly supports multiple writers. Git commits remain the authority for source changes. - -Canonical per-repo instructions for every agent (Claude, Codex, Gemini, Junie, Grok, …). Read this -before touching anything. - -## CODESCRIBE: The engine triangulation. - -> _The codescribe app had already pre 0.8.0 era, the app was using the "final pass" approach: the Whisper - the **only** transcription engine was transcribing the whole audio file - no live, no overlay no instant delivery. and replacing the live transcript with the final pass. The issue was that Whisper was not very confident and the final pass was not very accurate. Also, the final pass was not very fast and the app was not very responsive._ - -**The engine has layers and layers are our weapon, disquise and defense while nobody looks. The goal is to connect disquised and visible magic so the perfect transcript arrives as the "overlay show" with its backspace magic, that put the corrections live while speaking: Apple live speech recognition comes instantly with letter-level precision but is not very confident; Whisper tail-patches and fills gaps thanks to better context; finally lexicon corrects specialistic terms and punctuation on the fly** - -This means: -1. Apple Speech Delivers Instantly with letter precision but leaves gaps; -2. Whisper Transcribes on partial utterance-level "final passes" filling the gaps with better context - never replace the whole live transcript; -3. Lexicon corrections and punctuation are paralelly applied to the transcript; -4. Final pass is left as **opt-in** for regular runtime, but still acts as the lexicon hidden candidates donor. - -## Canonical Layer Order (Operator Directive, 2026-07-26, Verbatim) - -> → Neural instant letter-level transcript via Apple Speech API -> → Whisper transcribing partials on the go and all the time applying the -> patches -> → supervisor stays on duty final lexicon correction by substitution with -> heuristic dictionary! -> → human correction feeding lexicon perfectness." - -Apple Speech API — instant, letter-level, 100%-confidence live transcript. This is the canvas. It -transcribes only what it is sure of; its gaps are the voids the next layers fill. -Whisper on partials, on the go — transcribes during the session, filling canvas gaps as they -appear. Whisper is never a stop-time full-text authority. A full-file "final pass" that replaces -the live transcript is a doctrine violation. (On-the-go partial transcription now **exists** — -Layer 1 tail-patch runs on both live paths, including the default Apple progressive one, since -`a6b1233d`. It is **on by default** (`CODESCRIBE_LAYERED_TRANSCRIPTION` -unset → `phase1`; explicit `off`/`0`/`false` disarms). A stock install therefore -already runs live tail-patch on both live paths. The stop-path -`merge_live_whisper` remains the residual floor + gap fill — never a -full-replace. W13 fusion / idempotence / highlight / inline-format flags -stay OFF until an operator flip. The bar that ends the live-lane shame is -layered-ON ≥ lbrx file-mode on U-WER vs human at live latency — see -`docs/THE_ENGINE_ROADMAP.md` §13.) -Lexicon correction — the FINAL automated layer — substitution from dictionary heuristics, applied -after Whisper, at the end. -Human correction — feeds lexicon perfectness. The human loop teaches the dictionary; the -dictionary improves every day. - -### Why This Shape - -> "The final shape of the transcription pipeline is layered. It is about -> the fusion of Apple SoTA neural speech recognition engine -> (SFSpeechRecognizer), -> Whisper (https://openai.com/index/openai-whisper/) and human-curated -> daily feed of custom lexicon rules that patch the mistakes." - -Engi## ne triangulation IS the product: - -- **Apple's Neural Shyness**: Instant letter-level transcription, outputting only 100%-confident -letters (the live canvas floor). - -- **Whisper's Partial Pass**: Fills voids and partials on the go, but context-imprecise if treated -as a full replacement authority. -- **Lexicon Pass**: Final automated substitution based on dictionary heuristics, fed continuously -by human correction loops. - -Replacement destroys the trust map that makes this triangulation valuable. Under the append-plus- -gap-fill contract, these three forces combine into pure transcript purity. - -Anti-Patterns (Forbidden, Regardless of Who Proposes Them) - -Whisper (or any engine) replacing committed live text at stop time. -Lexicon running before Whisper, or being treated as a mid-stream layer. -Any "cleaner rewrite" of the overlay after the fact. -Windowed re-transcription that reorders or drops committed spans. -Inventing a different layer shape from memory. This file is the shape. - Past sessions contain abandoned ideas (per-request WAV path, Whisper-as-final-authority, - dictionary-first gap filling) — they are dead. Do not resurrect them. - - ## Measured Bars Guarding the Doctrine - - tests/e2e_overlay_delivery_parity.rs::e2e_apple_live_parity — **in the layer0 lane** the live - Apple canvas must reproduce the system dictation engine: similarity ≥ 0.90 plus deterministic - structural bars: head present, tail sealed, word-count ratio 0.9–1.1 (no duplicated phrases, no - lost spans). The layered lane is judged differently — see the next section, which is the - authority on which bar applies where. - **The bar is 0.90 and is not reproducibly green.** The "0.918–0.931 SFSpeech noise floor" this - file used to quote was n=4; the wider Layer-0 sample measured 2026-08-08 (lane-leaked runs - excluded) is 0.778 / 0.898 / 0.909 ×3 / 0.920 / 0.924 ×2 / 0.931 ×2 — 8 of 10 clear 0.90, two - do not. Treat a single green run as a sample, not as proof, and read the lane line the harness - now prints before trusting any number. - - ### Which ruler gates which lane - - **One rule, and it is enforced in code (`apple_rulers_gate`, `e2e_apple_live_parity`):** - a bar gates only the lane whose job matches the bar's reference. - - | lane | job | what GATES it | what is only measured | - |---|---|---|---| - | layer0 (`off`) | reproduce Apple's live canvas | similarity ≥ 0.90 vs Apple · ratio 0.9–1.1 · head · tail · lane match | accuracy-vs-human | - | layer1 (`phase1`) | diverge from Apple toward what was SAID | head · tail · ratio **floor** 0.9 (lost spans) · lane match | similarity vs Apple · ratio ceiling · accuracy-vs-human | - - **Why layer1 is not gated on Apple fidelity.** Gap-filling grows the denominator against an - Apple ruler, so a *more* accurate layer scores *lower*. This is measured, repeatedly, and the - sign is stable across every pair ever run: similarity falls, accuracy rises. The anchor is - deterministic and needs no microphone — `apple_reference_is_a_ruler_not_the_truth` pins the - Apple reference at **0.805** against the human transcription of the same audio, so 1.000 on - that bar would mean reproducing Apple's errors. Layer 0 is already slightly more accurate than - the ruler it is scored against. **Never make a layer less accurate to raise a number.** - - **Why accuracy-vs-human gates nothing either.** Its reference is a private fixture - (`~/.codescribe/data_assets`, never in the repo — deprivatize fence), so a bar on it would - evaporate silently on any tree without the operator's corpus. Both arms are printed for both - lanes; which number gates a merge stays an operator decision - (`.vibecrafted/plans/w12-layered-live-closure/reports/default-flip-memo-layered.md`). - Live numbers belong in the retained run logs under `target/e2e-blackhole/`, not in this file: - prose copies of them go stale within a run or two. - - **The structural cliff is the sharper edge.** The word-count ratio ceiling (1.1, scored against - Apple's token count) caps the capture at 188 tokens on this fixture while the spoken truth - carries 195 — so no layer can reach what was actually said without tripping it, and it fires - hardest exactly when Layer 1 is most accurate (measured live at 190 tokens, ratio 1.11, a hard - panic). The ceiling therefore does not gate the layered lane — but only when the excuse is - visible: with no human reference beside the fixture, gap-fill and duplication are - indistinguishable and the ceiling gates after all. `parity accuracy-headroom` prints the - remaining budget every run. - **Which target measures which lane** — the pin is per-target, so the lane is chosen by the - target you run, never by an env var you prepend: `make test-engine-parity` (Layer 0, pinned - off), `make test-engine-parity-layered` (phase1, the only incantation that actually arms - Layer 1), `make test-engine-parity-both` (runs both arms, prints both numbers and the delta). - Prepending `CODESCRIBE_LAYERED_TRANSCRIPTION=…` to any of them is now **refused** with exit 2: - a recipe pin beats CLI env, so that form silently measured the other lane and reported the - number as yours — it is how the W12 layered arm was recorded green while asserting nothing - (review P1-01). - **This instrument is operator-host-local, not CI.** Its whole corpus — the WAV, the Apple - reference and the human transcription — lives outside the repo, and nothing in - `.github/workflows/` runs it. On a checkout without the corpus these targets refuse rather - than measure. Treat parity as the bench you walk to, never as a bar a merge already cleared. - app/controller/mod.rs::adjudicate_recording_truth — "never full-replace live with Whisper"; - length-regression guard keeps the stream as the floor of truth. - - ## Working Rules - - Living Tree: Agents share one directory. Re-read files before editing; never revert other - agents' changes; commit in small packs with [/] titles and non-empty bulleted b - odies. - Loctree First: Structural questions (who imports X, blast radius, where a symbol lives) go to l - oct / loctree-mcp, not grep. Grep is for literal text only. - What Green Means: A verification command is authoritative only for what it executes, and no - surface may cite it as proof of something it does not run. Two gates, and only two: `make check` - (static — format, lint, semgrep, the env registry and the gate ledger; it executes ZERO tests) - and `make verify` (hermetic — the workspace tests plus doctests, no operator dotenv, no private - corpus, no Xcode, no API key). `make verify` is not a recipe that resembles CI, it IS the command - `.github/workflows/rust.yml` runs, so the two cannot drift. Everything else — the parity bars, - `make test-swift`, `smoke-macos27`, every real-API `make test*` lane — is a bench instrument: - real proof, this host only, never a bar a merge has already cleared. The classification lives in - the GATE LEDGER block of the Makefile, `make -s gate-ledger` prints it, and - `scripts/validate-gates.sh` (run by `check`, and by `tests/gate_registry.rs` inside `verify`) - fails when a verification target has no row, when a row names no target, or when a `ci=` claim - disagrees with `.github/workflows/`. This rule exists because `check` used to print "Quality gate - passed" having run nothing, and rust.yml called it "the full local gate incl. real-API / heavy - e2e tests" directly above a job that ran cargo itself. - Test Deadlines: In a test a clock is either the claim or a backstop — never both, and a backstop - must sit out of reach of machine load. These budgets wrap process spawn, not just the wait for a - reply: `spawn(python3) + initialize` for the MCP stdio mocks measures ~25 ms idle (n=12), so the - sub-second budgets that used to guard them were a bet that a loaded box is never 10x slower at - starting an interpreter. Losing that bet costs one of two things — a red that blames healthy code - (`unexpected error: Timed out waiting for MCP response to 'initialize'`, reproduced deliberately - 2026-08-08), or, where the assertion is merely `is_err()`, a green that never exercised the guard - at all. `core/mcp/client.rs::CONTENT_ASSERTION_BACKSTOP` is the pattern and carries the numbers; - a tight clock is legitimate only where the timeout is the thing asserted. - Attribution: Authored-By: — the agent that actually did the work. - No vendor default footers. - GitHub surface is English; chat with the operator is Polish. - Push/Merge/PR actions are operator buttons — prepare the one-liner, do not press it yourself. - UniFFI Bindings: After changing the core↔Swift bridge API, run make app-bindings — Xcode does - not regenerate them automatically. - Full App Build: scripts/build-app.sh (Developer ID signing keeps TCC grants stable across - rebuilds). - - 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍 with AI Agents by Vetcoders ©2024-2026 LibraxisAI +# Codescribe Local Agent Contract + +The VetCoders Global Agent Charter is authoritative in this repository. This +file adds only Codescribe-specific runtime laws and pointers; it does not create +a second workflow, dispatch plane, or worktree policy. + +## Runtime truth + +- One shared checkout is the Living Tree. Do not create implementation + worktrees for this repository. Re-read touched files and preserve concurrent + work. +- `RecordingController` is the single in-app microphone owner. Dictation, + Agent, and Assistive gestures may choose different downstream consumers, but + they must not create parallel recorders. +- `PresentationEmitter` is the transcript reducer of record. The clean + Transcript Bus observes committed reducer events; UI previews, raw engine + text, and a second transcription pass are not transcript authority. +- Delivery follows explicit operator intent. OS focus is not a substitute for + an Agent, canvas, clipboard, or paste route. +- Swift recording state is derived from controller lifecycle. A terminal event + must always release mic state, UI phase, and Agent-thread ownership. +- An Agent voice capture belongs to the thread selected when capture starts. + Browsing another thread must not steal the in-flight transcript. +- Diagnostic and CLI consumers follow the Transcript Bus. They must not open a + competing microphone merely to observe Codescribe.app. + +## Canonical contracts + +- `docs/STT_CONTRACT.md` — engine and adjudication truth. +- `docs/TRANSCRIPT_BUS.md` — clean event schema, privacy, and path resolution. +- `docs/HOTKEYS_CONTRACT.md` — gesture, ownership, and mode routing. +- `docs/DELIVERY_ROUTE.md` — destination selection when present on the active + stack. +- `docs/ENV_REGISTRY.toml` — every supported environment variable. + +When prose conflicts with executable behavior, establish runtime truth first, +then update both the code and the relevant contract in the same cut. + +## Working rules + +- Use Loctree before structural edits; use literal search only as the local + detail lens or explicit fallback. +- Never revert unfamiliar dirty changes. Isolate responsibilities, verify each + coherent cut, and stage only the files that belong to its checkpoint. +- Local implementation turns end in a scoped commit. Never push, merge, or + publish a release unless the operator explicitly asks for that action. +- Generated UniFFI Swift bindings must match the Rust bridge. Run + `make app-bindings` after bridge API changes. + +## Verification + +- `make check` — static formatting, Clippy, Semgrep, env registry, gate ledger. +- `make verify` — hermetic Rust tests and doctests; this is the CI contract. +- `make test-swift` — regenerate the ignored Xcode project, run phrase-restart + lockstep, then execute the Swift suite. +- Host-only corpus, real-API, loopback, and parity targets are bench evidence, + not implicit merge gates. State explicitly which ones ran and which were not + available. +- Production DMGs use the repository release contract, Developer ID signing, + notarization, checksum, and `verify-dmg`; never describe an ad-hoc package as + production. From df671b17c740153a77ab5fba5e7f62bf71a22a5c Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 11:01:22 +0200 Subject: [PATCH 6/8] [codex/vc-workflow] fix: seal transcript truth after final adjudication --- app/controller/mod.rs | 28 +++++++ app/controller/tests.rs | 40 ++++++++++ app/presentation/emitter.rs | 63 ++++++++------- app/presentation/transcript_bus.rs | 79 ++++++++++++------- bin/codescribe.rs | 29 ++++--- docs/TRANSCRIPT_BUS.md | 27 ++++--- .../Screens/Overlay/OverlayState.swift | 28 ++++--- macos/CodescribeTests/OverlayStateTests.swift | 16 ++++ 8 files changed, 215 insertions(+), 95 deletions(-) diff --git a/app/controller/mod.rs b/app/controller/mod.rs index bcc45094..3a3ed172 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -361,6 +361,10 @@ pub struct RecordingController { /// Current session ID for tracking session_id: Arc>>, + /// The one observer bus for the active recording. Presentation may publish + /// mutable drafts through it, but only the stop controller publishes the + /// immutable product seal after every automatic stage completes. + active_transcript_bus: Arc>>>, /// Task handle for delayed hold-start (800ms default) hold_start_task: Arc>>>, @@ -574,6 +578,7 @@ impl RecordingController { force_raw_mode: Arc::new(RwLock::new(false)), force_ai_mode: Arc::new(RwLock::new(false)), session_id: Arc::new(RwLock::new(None)), + active_transcript_bus: Arc::new(RwLock::new(None)), hold_start_task: Arc::new(Mutex::new(None)), hold_start_generation: Arc::new(AtomicU64::new(0)), start_transition_in_flight: Arc::new(AtomicBool::new(false)), @@ -637,6 +642,17 @@ impl RecordingController { publish_recording_indicator(BadgeMode::Processing, hold_indicator); } + /// Cross the product truth boundary exactly once. Engine finals and engine + /// session close are still mutable draft stages: Smart/Always final pass, + /// adjudication, dictionary cleanup, and formatting all happen later. The + /// text handed here is the same text used for history and delivery. + async fn seal_active_transcript(&self, text: String) { + let bus = self.active_transcript_bus.read().await.clone(); + if let Some(bus) = bus { + bus.publish_sealed(text, None); + } + } + /// Publish a cursor-badge mode, honoring the user's badge setting. async fn publish_indicator(&self, mode: BadgeMode) { let hold_indicator = self.config.read().await.hold_indicator; @@ -1131,6 +1147,7 @@ impl RecordingController { *self.force_raw_mode.write().await = false; *self.force_ai_mode.write().await = false; *self.session_id.write().await = None; + *self.active_transcript_bus.write().await = None; *self.assistive_context.write().await = None; *self.pre_overlay_frontmost_app.write().await = None; self.start_transition_in_flight @@ -2105,6 +2122,7 @@ impl RecordingController { let hold_start_generation = Arc::clone(&self.hold_start_generation); let start_transition_in_flight = Arc::clone(&self.start_transition_in_flight); let session_telemetry = Arc::clone(&self.session_telemetry); + let active_transcript_bus = Arc::clone(&self.active_transcript_bus); let task = tokio::spawn(async move { // Wait for the configured delay @@ -2284,6 +2302,7 @@ impl RecordingController { } } + *active_transcript_bus.write().await = transcript_bus.clone(); if let Some(bus) = &transcript_bus { bus.publish_started(); } @@ -2496,6 +2515,7 @@ impl RecordingController { return Err(e); } } + *self.active_transcript_bus.write().await = transcript_bus.clone(); if let Some(bus) = &transcript_bus { bus.publish_started(); } @@ -3661,6 +3681,7 @@ impl RecordingController { // No-speech stops still paid the final pass — keep the stage // receipt so latency truth covers every real stop. info!("{}", format_final_pass_stages_line(final_pass_stages)); + self.seal_active_transcript(String::new()).await; return Ok(ProcessRecordingOutcome::no_speech(reason)); } }; @@ -4194,6 +4215,13 @@ impl RecordingController { let final_formatted_text = formatted_text.clone(); + // This is the first point at which the text is product-final: live + // layers, optional file/cloud adjudication, dictionary cleanup, and + // formatting are all complete. Seal the same bytes that history and + // delivery consume; the bus rejects every later machine write. + self.seal_active_transcript(final_formatted_text.clone()) + .await; + // Surface the authoritative final transcript to external dictation surfaces // (the SwiftUI overlay). This is the same `final_formatted_text` that is // pasted (auto-delivery) and written to history (tray "Copy"), so the overlay diff --git a/app/controller/tests.rs b/app/controller/tests.rs index 922c05f1..4bee1463 100644 --- a/app/controller/tests.rs +++ b/app/controller/tests.rs @@ -13,6 +13,46 @@ async fn test_initial_state() { assert_eq!(controller.current_state().await, State::Idle); } +/// Product truth crosses one controller-owned boundary. The first seal wins; +/// a late automatic rewrite cannot append another truth event. +#[tokio::test] +async fn test_controller_product_seal_is_first_writer_wins() { + use crate::presentation::transcript_bus::CleanTranscriptEvent; + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("controller-seal.jsonl"); + let bus = Arc::new( + TranscriptBus::open_at( + TranscriptSession { + session_id: "controller-seal".to_string(), + mode: TranscriptMode::Dictation, + }, + path.clone(), + Some(48_000), + ) + .unwrap(), + ); + let controller = RecordingController::new(); + *controller.active_transcript_bus.write().await = Some(bus); + + controller + .seal_active_transcript("sealed committed truth".to_string()) + .await; + controller + .seal_active_transcript("late automatic rewrite".to_string()) + .await; + + let events = std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].status, "session_started"); + assert_eq!(events[1].status, "transcript_sealed"); + assert_eq!(events[1].text, "sealed committed truth"); +} + /// The paste target reports the app latched before the overlay took focus, and /// `None` when nothing was latched — the delivery path must never guess a target. #[tokio::test] diff --git a/app/presentation/emitter.rs b/app/presentation/emitter.rs index 51aaf8c2..6d78c6f8 100644 --- a/app/presentation/emitter.rs +++ b/app/presentation/emitter.rs @@ -14,7 +14,7 @@ use codescribe_core::pipeline::streaming::BufferedEmitter; use tokio::sync::Mutex; use tracing::{debug, info}; -use super::transcript_bus::{CommittedTranscript, TranscriptBus}; +use super::transcript_bus::{TranscriptBus, TranscriptDraft, TranscriptDraftStatus}; /// Commands sent through the ordered channel to the emitter worker. enum EmitterCmd { @@ -34,7 +34,7 @@ pub enum DeltaRenderMode { ActivePreviewOnly, } -/// One committed utterance. `text` is the corrected string every later +/// One mutable engine-finalized utterance. `text` is the working string every later /// `ReplaceRange` / `InsertAnnotation` char offset is computed against; /// `raw_text` keeps the uncorrected engine output for the quality loop. #[derive(Debug, Clone, PartialEq)] @@ -50,8 +50,8 @@ struct TranscriptUtteranceRecord { impl TranscriptUtteranceRecord { /// Narrow the reducer's internal record to the clean public bus contract. /// `raw_text` is deliberately excluded at this boundary. - fn clean_transcript(&self) -> CommittedTranscript { - CommittedTranscript { + fn clean_draft(&self) -> TranscriptDraft { + TranscriptDraft { utterance_id: self.utterance_id, text: self.text.clone(), start_seconds: self.start_ts, @@ -588,7 +588,7 @@ impl EventSink for PresentationEmitter { let revised = state .apply_correction(previous_text, text) .and_then(|index| state.committed.get(index)) - .map(TranscriptUtteranceRecord::clean_transcript); + .map(TranscriptUtteranceRecord::clean_draft); let rendered = match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), @@ -596,7 +596,7 @@ impl EventSink for PresentationEmitter { (rendered, revised) }; if let (Some(bus), Some(revised)) = (&self.transcript_bus, revised) { - bus.publish_utterance("utterance_revised", revised); + bus.publish_draft(TranscriptDraftStatus::Revised, revised); } self.send_cmd(EmitterCmd::SetTargetText(rendered)); } @@ -612,15 +612,15 @@ impl EventSink for PresentationEmitter { .committed .iter() .rfind(|record| record.utterance_id == *utterance_id) - .map(TranscriptUtteranceRecord::clean_transcript); + .map(TranscriptUtteranceRecord::clean_draft); (callback_payload, committed, existed) }; if let (Some(bus), Some(committed)) = (&self.transcript_bus, committed) { - bus.publish_utterance( + bus.publish_draft( if revised { - "utterance_revised" + TranscriptDraftStatus::Revised } else { - "utterance_committed" + TranscriptDraftStatus::Created }, committed, ); @@ -709,15 +709,6 @@ impl EventSink for PresentationEmitter { state.rendered_text() }; self.send_cmd(EmitterCmd::SetTargetText(rendered)); - if let Some(bus) = &self.transcript_bus { - bus.publish_final( - self.session_state - .lock() - .unwrap_or_else(|error| error.into_inner()) - .streaming_floor(), - None, - ); - } // Stats is the last event from transcription_session. // Signal BufferedEmitter to finish through the ordered channel, // ensuring all pending pushes are processed first. @@ -747,20 +738,20 @@ impl EventSink for PresentationEmitter { .committed .iter() .rfind(|record| record.utterance_id == utterance_id) - .map(TranscriptUtteranceRecord::clean_transcript); + .map(TranscriptUtteranceRecord::clean_draft); (rendered, revised) } else { (None, None) } }; if let (Some(bus), Some(revised)) = (&self.transcript_bus, revised) { - bus.publish_utterance("utterance_revised", revised); + bus.publish_draft(TranscriptDraftStatus::Revised, revised); } if let Some(rendered) = rendered { self.send_cmd(EmitterCmd::SetTargetText(rendered)); } } - EngineEvent::SessionFinalised { session_id, .. } => { + EngineEvent::SessionFinalised { .. } => { // The Apple progressive lane closes with SessionFinalised and // does not emit Stats. Persist only immutable canvas here: a // cumulative final can re-state committed text as the last @@ -771,9 +762,9 @@ impl EventSink for PresentationEmitter { state.clear_live_preview(); state.streaming_floor() }; - if let Some(bus) = &self.transcript_bus { - bus.publish_final(rendered.clone(), Some(session_id.clone())); - } + // Engine close is not product truth. The controller can still + // run Smart/Always final pass, adjudication, postprocess, and + // formatting. Only that controller result may seal the bus. self.send_cmd(EmitterCmd::SetTargetText(rendered)); self.send_cmd(EmitterCmd::Finish); } @@ -1458,10 +1449,10 @@ mod tests { } /// Dictation and Agent differ only in metadata/consumer choice. The exact - /// same engine fixture must produce byte-equivalent committed truth and an - /// Agent utterance event before the stop-time session final. + /// same engine fixture must produce byte-equivalent draft events. The + /// controller-owned product seal is simulated explicitly after engine close. #[tokio::test] - async fn dictation_and_agent_publish_identical_committed_events_before_consumers() { + async fn dictation_and_agent_publish_identical_drafts_before_controller_seal() { use crate::presentation::transcript_bus::{ CleanTranscriptEvent, TranscriptBus, TranscriptMode, TranscriptSession, }; @@ -1484,8 +1475,12 @@ mod tests { .unwrap(), ); let transcript = Arc::new(Mutex::new(String::new())); - let emitter = - PresentationEmitter::new_with_transcript_bus(transcript, None, None, Some(bus)); + let emitter = PresentationEmitter::new_with_transcript_bus( + transcript, + None, + None, + Some(Arc::clone(&bus)), + ); emitter.on_event(&EngineEvent::Preview { rev: 1, text: "shared clean truth".to_string(), @@ -1511,6 +1506,10 @@ mod tests { session_id: format!("pipeline-{session_id}"), layer_summary: LayerSummary::default(), }); + bus.publish_sealed( + "shared clean truth".to_string(), + Some(format!("pipeline-{session_id}")), + ); std::fs::read_to_string(path) .unwrap() @@ -1543,8 +1542,8 @@ mod tests { .collect::>() }; assert_eq!(comparable(&dictation), comparable(&agent)); - assert_eq!(agent[1].status, "utterance_committed"); - assert_eq!(agent[2].status, "session_finalized"); + assert_eq!(agent[1].status, "utterance_draft"); + assert_eq!(agent[2].status, "transcript_sealed"); assert!( !agent .iter() diff --git a/app/presentation/transcript_bus.rs b/app/presentation/transcript_bus.rs index 81dd589c..66dfa00a 100644 --- a/app/presentation/transcript_bus.rs +++ b/app/presentation/transcript_bus.rs @@ -1,7 +1,8 @@ //! Durable clean transcript events for operator and control-plane consumers. //! -//! The bus is an observer of the committed [`PresentationEmitter`] reducer. It -//! never opens audio, re-transcribes a file, or reconstructs text from UI +//! The bus observes the mutable [`PresentationEmitter`] draft and the one +//! authoritative product seal chosen by [`crate::controller::RecordingController`]. +//! It never opens audio, re-transcribes a file, or reconstructs text from UI //! deltas. One append-only JSON object is flushed per state transition. use std::fs::{File, OpenOptions}; @@ -37,9 +38,9 @@ pub struct TranscriptSession { pub mode: TranscriptMode, } -/// One utterance after it has entered the authoritative committed reducer. +/// One mutable utterance slot in the live transcript draft. #[derive(Debug, Clone, PartialEq)] -pub struct CommittedTranscript { +pub struct TranscriptDraft { pub utterance_id: u64, pub text: String, pub start_seconds: f32, @@ -47,6 +48,23 @@ pub struct CommittedTranscript { pub segments: Vec, } +/// Typed draft transition. Product truth is never represented by this enum; +/// only [`TranscriptBus::publish_sealed`] can cross the immutable boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranscriptDraftStatus { + Created, + Revised, +} + +impl TranscriptDraftStatus { + fn as_str(self) -> &'static str { + match self { + Self::Created => "utterance_draft", + Self::Revised => "utterance_revised", + } + } +} + /// Append-only public event contract. `text` is always clean reducer truth; /// unfiltered engine `raw_text` never crosses this boundary. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -70,9 +88,9 @@ pub struct CleanTranscriptEvent { pub pipeline_session_id: Option, } -/// Synchronous low-frequency writer. Commits occur on the STT worker, not the -/// CoreAudio callback, and each line is flushed so a live tailer sees it before -/// the next utterance or process exit. +/// Synchronous low-frequency writer. Draft boundaries occur on the STT worker, +/// not the CoreAudio callback, and each line is flushed so a live tailer sees +/// them before the next utterance or process exit. pub struct TranscriptBus { session: TranscriptSession, path: PathBuf, @@ -87,7 +105,7 @@ struct TranscriptBusWriter { file: File, sequence: u64, started: bool, - finalized: bool, + sealed: bool, } impl TranscriptBus { @@ -136,7 +154,7 @@ impl TranscriptBus { file, sequence: 0, started: false, - finalized: false, + sealed: false, }), sample_rate_override, }; @@ -160,8 +178,9 @@ impl TranscriptBus { } } - /// Publish a new committed slot or a later bounded revision of that slot. - pub fn publish_utterance(&self, status: &'static str, utterance: CommittedTranscript) { + /// Publish a new mutable utterance slot or a bounded revision of that slot. + pub fn publish_draft(&self, status: TranscriptDraftStatus, utterance: TranscriptDraft) { + let status = status.as_str(); let sample_rate = self.sample_rate(); let event = CleanTranscriptEvent { schema: "codescribe.transcript.v1".to_string(), @@ -185,8 +204,8 @@ impl TranscriptBus { .writer .lock() .unwrap_or_else(|error| error.into_inner()); - if writer.finalized { - tracing::warn!(session_id = %self.session.session_id, %status, "clean transcript event ignored after session finalization"); + if writer.sealed { + tracing::warn!(session_id = %self.session.session_id, %status, "transcript draft ignored after product seal"); return; } if let Err(error) = self @@ -197,13 +216,15 @@ impl TranscriptBus { } } - /// Publish the immutable session canvas at the engine close boundary. - pub fn publish_final(&self, text: String, pipeline_session_id: Option) { + /// Publish the one immutable product truth after every configured automatic + /// stage (engine layers, final pass, adjudication, postprocess, formatting) + /// has completed. The first call wins byte-for-byte; later calls are ignored. + pub fn publish_sealed(&self, text: String, pipeline_session_id: Option) { let mut writer = self .writer .lock() .unwrap_or_else(|error| error.into_inner()); - if writer.finalized { + if writer.sealed { return; } let event = CleanTranscriptEvent { @@ -213,7 +234,7 @@ impl TranscriptBus { mode: self.session.mode, utterance_id: None, emitted_at: String::new(), - status: "session_finalized".to_string(), + status: "transcript_sealed".to_string(), sample_rate_hz: self.sample_rate(), sample_start: None, sample_end: None, @@ -227,7 +248,7 @@ impl TranscriptBus { .ensure_started_locked(&mut writer) .and_then(|_| self.write_event_locked(&mut writer, event)) { - Ok(()) => writer.finalized = true, + Ok(()) => writer.sealed = true, Err(error) => self.log_write_error(error), } } @@ -343,7 +364,7 @@ mod tests { use super::*; #[test] - fn bus_flushes_start_commit_and_final_as_private_ndjson() { + fn bus_flushes_start_draft_and_seal_as_private_ndjson() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("events.jsonl"); let bus = TranscriptBus::open_at( @@ -355,9 +376,9 @@ mod tests { Some(48_000), ) .unwrap(); - bus.publish_utterance( - "utterance_committed", - CommittedTranscript { + bus.publish_draft( + TranscriptDraftStatus::Created, + TranscriptDraft { utterance_id: 7, text: "clean final".to_string(), start_seconds: 0.25, @@ -365,13 +386,13 @@ mod tests { segments: Vec::new(), }, ); - bus.publish_final( + bus.publish_sealed( "clean final".to_string(), Some("engine-session".to_string()), ); - bus.publish_utterance( - "utterance_revised", - CommittedTranscript { + bus.publish_draft( + TranscriptDraftStatus::Revised, + TranscriptDraft { utterance_id: 7, text: "must not escape finalization".to_string(), start_seconds: 0.25, @@ -379,7 +400,7 @@ mod tests { segments: Vec::new(), }, ); - bus.publish_final("duplicate final".to_string(), None); + bus.publish_sealed("duplicate final".to_string(), None); let lines: Vec = std::fs::read_to_string(&path) .unwrap() @@ -388,10 +409,10 @@ mod tests { .collect(); assert_eq!(lines.len(), 3); assert_eq!(lines[0].status, "session_started"); - assert_eq!(lines[1].status, "utterance_committed"); + assert_eq!(lines[1].status, "utterance_draft"); assert_eq!(lines[1].sample_start, Some(12_000)); assert_eq!(lines[1].sample_end, Some(72_000)); - assert_eq!(lines[2].status, "session_finalized"); + assert_eq!(lines[2].status, "transcript_sealed"); assert_eq!(lines[2].text, "clean final"); assert_eq!( lines.iter().map(|event| event.sequence).collect::>(), diff --git a/bin/codescribe.rs b/bin/codescribe.rs index 644d2ee9..00256cf1 100644 --- a/bin/codescribe.rs +++ b/bin/codescribe.rs @@ -20,7 +20,8 @@ //! - `-f/--format` = the AI-formatted lane (same `ai_formatting` call and //! lane config the GUI uses; requires a configured key). //! - `transcribe live` = follow the app-owned clean transcript bus and flush -//! newly committed utterances to stdout one line at a time. It never opens a +//! newly created utterance drafts to stdout one line at a time. Revisions and +//! the final product seal remain explicit bus events. It never opens a //! second microphone or reconstructs text from UI previews. //! //! Provenance goes to stderr, GUI-truth style, so stdout stays pipeable. @@ -64,7 +65,7 @@ enum Command { #[derive(Subcommand)] enum TranscribeMode { - /// Follow the app's committed transcript bus; Ctrl-C closes the reader + /// Follow the app's transcript draft/seal bus; Ctrl-C closes the reader Live, } @@ -110,7 +111,7 @@ fn transcribe_live(language: Option) -> anyhow::Result<()> { .build()?; runtime.block_on(async move { - eprintln!("codescribe live: app transcript bus -> committed stdout"); + eprintln!("codescribe live: app transcript bus -> live draft stdout"); eprintln!("bus={} start=end stop=Ctrl-C", path.display()); eprintln!( "language_hint={} owner=Codescribe.app", @@ -170,17 +171,23 @@ fn transcribe_live(language: Option) -> anyhow::Result<()> { .map(|id| id.to_string()) .unwrap_or_else(|| "unknown".to_string()) ); + } else if event.status == "transcript_sealed" { + eprintln!( + "codescribe live: transcript sealed session={} chars={}", + event.session_id, + event.text.chars().count() + ); } } } }) } -/// Plain stdout is intentionally append-only. Revisions remain machine-readable -/// in the canonical NDJSON bus and are announced on stderr without transcript -/// content; consumers that need patch semantics should follow the bus directly. +/// Plain stdout is intentionally append-only and therefore shows each new draft +/// slot once. Revisions and the final seal remain machine-readable in the +/// canonical NDJSON bus and are announced on stderr without transcript content. fn live_event_text<'a>(status: &str, text: &'a str) -> Option<&'a str> { - if status != "utterance_committed" { + if status != "utterance_draft" { return None; } let text = text.trim(); @@ -297,13 +304,13 @@ mod tests { } #[test] - fn live_plain_text_emits_only_nonempty_commits() { + fn live_plain_text_emits_only_nonempty_new_drafts() { assert_eq!( - live_event_text("utterance_committed", " instrukcja "), + live_event_text("utterance_draft", " instrukcja "), Some("instrukcja") ); - assert_eq!(live_event_text("utterance_committed", " "), None); + assert_eq!(live_event_text("utterance_draft", " "), None); assert_eq!(live_event_text("utterance_revised", "poprawka"), None); - assert_eq!(live_event_text("session_finalized", "całość"), None); + assert_eq!(live_event_text("transcript_sealed", "całość"), None); } } diff --git a/docs/TRANSCRIPT_BUS.md b/docs/TRANSCRIPT_BUS.md index 4dc24abb..821d2314 100644 --- a/docs/TRANSCRIPT_BUS.md +++ b/docs/TRANSCRIPT_BUS.md @@ -1,9 +1,9 @@ # Clean Transcript Bus -Codescribe publishes one private, append-only NDJSON stream from the committed -`PresentationEmitter` reducer. Dictation, Agent, and Assistive share the same -capture, VAD, STT, correction, and publication path; mode changes only the -downstream paste/format/Agent-delivery consumer. +Codescribe publishes one private, append-only NDJSON stream containing mutable +live transcript drafts and one immutable product seal. Dictation, Agent, and +Assistive share the same capture, VAD, STT, correction, and publication path; +mode changes only the downstream paste/format/Agent-delivery consumer. This bus is an observer. It does not open a microphone, scrape SwiftUI, or re-transcribe saved audio. @@ -28,13 +28,18 @@ Each line is one JSON object with: - `sequence`, `session_id`, `mode`, `utterance_id`, `emitted_at`, `status` - `sample_rate_hz`, `sample_start`, `sample_end` - `audio_start_seconds`, `audio_end_seconds` -- committed `text`, structured `segments`, optional `pipeline_session_id` - -Statuses are `session_started`, `utterance_committed`, -`utterance_revised`, and `session_finalized`. A revision keeps the original -utterance identity. `raw_text` and unstable UI previews never cross this -boundary. Every line is flushed before publication returns, so live consumers -can observe committed speech while recording is still active. +- clean draft or sealed `text`, structured `segments`, optional + `pipeline_session_id` + +Statuses are `session_started`, `utterance_draft`, `utterance_revised`, and +`transcript_sealed`. A revision keeps the original utterance identity. +`UtteranceFinal` and engine `SessionFinalised` are working boundaries, not +product truth: Smart/Always final pass, adjudication, dictionary cleanup, and +formatting can still change the entire result. Only the controller output used +for history and delivery becomes `transcript_sealed`; the bus rejects all later +machine writes. `raw_text` and unstable character-by-character UI previews never +cross this boundary. Every line is flushed before publication returns, so live +consumers can observe drafts while recording is active. Example consumer: diff --git a/macos/Codescribe/Screens/Overlay/OverlayState.swift b/macos/Codescribe/Screens/Overlay/OverlayState.swift index 64acc0f7..889e5c9d 100644 --- a/macos/Codescribe/Screens/Overlay/OverlayState.swift +++ b/macos/Codescribe/Screens/Overlay/OverlayState.swift @@ -1594,20 +1594,25 @@ final class OverlayState: ObservableObject { } /// The Rust controller's authoritative post-stop transcript (LocalFinalPass) — - /// the SAME text that is delivered/pasted and shown by tray "Copy". Stored so + /// the SAME text that is delivered/pasted and shown by tray "Copy". This is + /// the product seal: the first non-empty value wins byte-for-byte and no later + /// machine event may replace it. Stored so /// the single `finalizeTranscript()` uses it instead of the raw streaming /// assembly. Emitted inside the awaited stop pipeline, so it normally arrives /// before the stop/finalise events; if it arrives AFTER (mode already /// `.formatted`), replace the FINAL immediately. Live PREVIEW is untouched — /// it stays raw-streaming on purpose ("live preview · raw"). func applyFinalTranscript(_ text: String) { - let clean = text.trimmingCharacters(in: .whitespacesAndNewlines) - // Dedupe: this event fires once per stop, but a redundant re-emit must not - // reassign `@Published` state (each write re-invalidates the TextEditor). - guard !clean.isEmpty, clean != authoritativeFinalText else { return } - authoritativeFinalText = clean + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + if let sealed = authoritativeFinalText { + if text != sealed { + NSLog("codescribe: rejected automatic FinalTranscript rewrite after product seal") + } + return + } + authoritativeFinalText = text formatFailureStatus = nil - let rendered = insertingContextMarkers(into: clean) + let rendered = insertingContextMarkers(into: text) if mode == .formatted, formattedText != rendered { formattedText = rendered armAutoFormatRevertSlot(shown: rendered) @@ -1618,14 +1623,14 @@ final class OverlayState: ObservableObject { mode = .formatted restartAutoHideCountdown() } - if deliveredText.isEmpty, !clean.isEmpty { + if deliveredText.isEmpty { deliveredText = rendered } if agentSessionArmed { agentFinalTranscriptAppeared = true } - if sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !clean.isEmpty { - sttRawText = clean + if sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + sttRawText = text } } @@ -1702,8 +1707,7 @@ final class OverlayState: ObservableObject { private var usableAuthoritativeFinalText: String? { guard let text = authoritativeFinalText else { return nil } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : text } private func resetTranscript() { diff --git a/macos/CodescribeTests/OverlayStateTests.swift b/macos/CodescribeTests/OverlayStateTests.swift index 32ecd7cd..bcc7c659 100644 --- a/macos/CodescribeTests/OverlayStateTests.swift +++ b/macos/CodescribeTests/OverlayStateTests.swift @@ -915,6 +915,22 @@ final class OverlayStateTests: XCTestCase { XCTAssertEqual(state.formattedText, "raw take") } + func testProductSealRejectsMachineRewriteButAllowsHumanEdit() { + let clock = OverlayStateTestClock() + let state = OverlayState(nowProvider: { clock.now }) + state.handleRecordingPreparing() + state.handleRecordingStarted() + state.applyFinal(utteranceId: 1, "working draft") + state.applyFinalTranscript(" sealed committed truth ") + state.finishControllerRecording() + + state.applyFinalTranscript("late automatic rewrite") + XCTAssertEqual(state.formattedText, " sealed committed truth ") + + state.userEditedTranscript("human-authored version") + XCTAssertEqual(state.formattedText, "human-authored version") + } + func testAutoFormatOffNeverArmsRevertSlot() { let clock = OverlayStateTestClock() let engine = OverlayStateTestEngine() From f40b9f42d01208fd7e773909f06b04981d477fbc Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 11:02:00 +0200 Subject: [PATCH 7/8] [codex/vc-workflow] style: restore Swift format gate --- .../Screens/Overlay/OverlayHighlight.swift | 3 ++- .../Codescribe/Screens/Overlay/OverlayState.swift | 14 ++++++++------ .../Codescribe/Screens/Settings/EnginePanel.swift | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/macos/Codescribe/Screens/Overlay/OverlayHighlight.swift b/macos/Codescribe/Screens/Overlay/OverlayHighlight.swift index 57274494..c8d530c4 100644 --- a/macos/Codescribe/Screens/Overlay/OverlayHighlight.swift +++ b/macos/Codescribe/Screens/Overlay/OverlayHighlight.swift @@ -135,7 +135,8 @@ enum OverlayCanvas { text: String, highlights: [OverlayHighlight] ) { - let lexicon = highlights + let lexicon = + highlights .filter { $0.kind == .lexiconCorrected } .sorted { $0.charStart < $1.charStart } var cursor = 0 diff --git a/macos/Codescribe/Screens/Overlay/OverlayState.swift b/macos/Codescribe/Screens/Overlay/OverlayState.swift index 889e5c9d..2226e48f 100644 --- a/macos/Codescribe/Screens/Overlay/OverlayState.swift +++ b/macos/Codescribe/Screens/Overlay/OverlayState.swift @@ -1483,12 +1483,14 @@ final class OverlayState: ObservableObject { end: end, replacementCount: UInt64(text.count) ) - if highlightsEnabled, source == .lexicon, let highlight = OverlayCanvas.lexiconHighlight( - utteranceId: utteranceId, - start: start, - replacement: text, - before: replaced - ) { + if highlightsEnabled, source == .lexicon, + let highlight = OverlayCanvas.lexiconHighlight( + utteranceId: utteranceId, + start: start, + replacement: text, + before: replaced + ) + { highlights.append(highlight) } syncCommittedUtterances() diff --git a/macos/Codescribe/Screens/Settings/EnginePanel.swift b/macos/Codescribe/Screens/Settings/EnginePanel.swift index 552160bc..a0668ea7 100644 --- a/macos/Codescribe/Screens/Settings/EnginePanel.swift +++ b/macos/Codescribe/Screens/Settings/EnginePanel.swift @@ -501,8 +501,8 @@ struct EnginePanel: View { Text( "Rest the Apple engine after this much silence; the next speech edge wakes a fresh epoch so Whisper can patch the sealed span" ) - .font(CSFont.ui(11.5)) - .foregroundStyle(CSColor.textMutedAlt) + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textMutedAlt) } Spacer(minLength: 12) Text(String(format: "%.1f s", model.settings.toggleSilenceSec)) From 1706c617ddc0eeb37b4d7bf32610f673ff3b2c42 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sun, 16 Aug 2026 13:34:51 +0200 Subject: [PATCH 8/8] [grok/vc-implement] feat: overlay Insert click is a delivery-throne constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop path already asked resolve_delivery_route. Overlay Insert and Paste Here still picked a destination on their own, which is why OverlayInsert and DeferredInsert were dead in the lib target. - paste_text_from_overlay and defer_text_from_overlay consult the throne - OverlayInsert + Codescribe (latched target or caret) → DeferredInsert - OverlayInsert + foreign app → ClipboardPaste; Orient vetoes do not apply - auto-paste policy matrix stays test-only; EngineContract is serialize-only Authored-By: grok --- app/controller/delivery_route.rs | 88 ++++++++++++++++++++++++++++-- app/controller/mod.rs | 88 ++++++++++++++++++++++-------- app/controller/quality_delivery.rs | 12 +++- core/quality/engine_contract.rs | 7 ++- docs/DELIVERY_ROUTE.md | 11 +++- 5 files changed, 170 insertions(+), 36 deletions(-) diff --git a/app/controller/delivery_route.rs b/app/controller/delivery_route.rs index 54458349..42416c08 100644 --- a/app/controller/delivery_route.rs +++ b/app/controller/delivery_route.rs @@ -25,7 +25,8 @@ pub enum DeliveryRoute { /// Auto-paste / overlay Insert into the *latched session target*. /// Focus at stop time is not the authority. ClipboardPaste, - /// Armed for a later explicit Paste Here. + /// Armed for a later explicit Paste Here. Constructed when the overlay + /// Insert / defer click refuses a synthetic paste into Codescribe. DeferredInsert, /// History / notes / RAW only — no user-visible delivery. ArchiveOnly, @@ -60,7 +61,7 @@ pub enum DeliveryIntent { AgentVoice, /// Explicit overlay "To Agent" after any session. OverlayToAgent, - /// Explicit overlay Insert / Paste Here. + /// Explicit overlay Insert / Paste Here. Frozen at the click, not at stop. OverlayInsert, /// Notes-only / save-only. NotesOnly, @@ -123,6 +124,23 @@ pub fn target_is_self_app(name: &str) -> bool { name.trim().eq_ignore_ascii_case("codescribe") } +/// Facts an overlay Insert / defer click may feed the throne. +/// +/// Focus-at-click is not an input. `latched_target_is_self` is true when the +/// recorded target is Codescribe, or when Swift already knows the caret is +/// still inside our chrome (`defer_text_from_overlay`). +pub fn overlay_insert_facts(has_text: bool, latched_target_is_self: bool) -> DeliveryFacts { + DeliveryFacts { + has_text, + no_speech: false, + auto_paste_enabled: false, + overlay_enabled: true, + live_stream_session: false, + commit_required: false, + latched_target_is_self, + } +} + /// Single destination function. Advisors (quality gate, overlay flag, auto-paste /// toggle) may veto a paste; they may not pick a different throne. pub fn resolve_delivery_route(intent: DeliveryIntent, facts: DeliveryFacts) -> DeliveryDecision { @@ -146,14 +164,27 @@ pub fn resolve_delivery_route(intent: DeliveryIntent, facts: DeliveryFacts) -> D route: DeliveryRoute::ArchiveOnly, reason: "notes_save_only", }, - DeliveryIntent::OverlayInsert => DeliveryDecision { - route: DeliveryRoute::ClipboardPaste, - reason: "explicit_insert", - }, + DeliveryIntent::OverlayInsert => overlay_insert_route(facts), DeliveryIntent::OrientDictation | DeliveryIntent::OrientFormat => orient_route(facts), } } +/// Explicit overlay click. Orient vetoes (live stream, quality commit) do not +/// apply — the user asked to insert *now*. Codescribe as the latched target +/// still refuses Cmd+V into ourselves. +fn overlay_insert_route(facts: DeliveryFacts) -> DeliveryDecision { + if facts.latched_target_is_self { + return DeliveryDecision { + route: DeliveryRoute::DeferredInsert, + reason: "refuse_paste_into_self", + }; + } + DeliveryDecision { + route: DeliveryRoute::ClipboardPaste, + reason: "explicit_insert", + } +} + fn orient_route(facts: DeliveryFacts) -> DeliveryDecision { if facts.live_stream_session { return DeliveryDecision { @@ -322,6 +353,51 @@ mod tests { assert_eq!(live.reason, "live_stream_owns_canvas"); } + #[test] + fn overlay_insert_to_foreign_app_is_clipboard_paste() { + let decision = resolve_delivery_route(DeliveryIntent::OverlayInsert, facts(|_| {})); + assert_eq!(decision.route, DeliveryRoute::ClipboardPaste); + assert_eq!(decision.reason, "explicit_insert"); + assert!(decision.route.posts_synthetic_paste()); + } + + #[test] + fn overlay_insert_into_self_is_deferred() { + let decision = resolve_delivery_route( + DeliveryIntent::OverlayInsert, + facts(|f| { + f.latched_target_is_self = true; + f.auto_paste_enabled = true; + }), + ); + assert_eq!(decision.route, DeliveryRoute::DeferredInsert); + assert_eq!(decision.reason, "refuse_paste_into_self"); + assert!(!decision.route.posts_synthetic_paste()); + } + + #[test] + fn overlay_insert_ignores_live_stream_and_commit_vetoes() { + let decision = resolve_delivery_route( + DeliveryIntent::OverlayInsert, + facts(|f| { + f.live_stream_session = true; + f.commit_required = true; + }), + ); + assert_eq!(decision.route, DeliveryRoute::ClipboardPaste); + assert_eq!(decision.reason, "explicit_insert"); + } + + #[test] + fn overlay_insert_facts_are_the_click_constructor() { + let click = overlay_insert_facts(true, true); + assert!(!click.auto_paste_enabled); + assert!(click.overlay_enabled); + assert!(click.latched_target_is_self); + let decision = resolve_delivery_route(DeliveryIntent::OverlayInsert, click); + assert_eq!(decision.route, DeliveryRoute::DeferredInsert); + } + #[test] fn notes_only_never_pastes() { let decision = resolve_delivery_route(DeliveryIntent::NotesOnly, facts(|_| {})); diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 3a3ed172..ad5910e1 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -101,7 +101,7 @@ use assistive_delivery::{ }; use delivery_route::{ DeliveryFacts, DeliveryIntent, DeliveryRoute, delivery_intent_from_session, - format_delivery_route_line, resolve_delivery_route, target_is_self_app, + format_delivery_route_line, overlay_insert_facts, resolve_delivery_route, target_is_self_app, }; pub(crate) use final_pass::{ FinalPassAction, FinalPassRoutingMode, FinalPassStages, SmartTailGapSource, StopPathBudget, @@ -910,16 +910,30 @@ impl RecordingController { self.pre_overlay_frontmost_app.read().await.clone() } - /// Paste user-edited overlay text through the same controller-owned delivery - /// path as automatic dictation delivery: restore the pre-overlay target app, - /// apply transcript tagging config, then synthesize Cmd+V via clipboard. + /// Paste user-edited overlay text through the delivery throne, then restore + /// the latched target and synthesize Cmd+V via clipboard. /// - /// Delivery is fail-closed: Cmd+V is posted only when the runtime frontmost - /// app exactly matches the latched target and Accessibility permits event - /// posting. Every unconfirmed case becomes a tagged clipboard copy. + /// `resolve_delivery_route(OverlayInsert)` picks the destination. Codescribe + /// as the latched target arms Paste Here instead of pasting into ourselves. + /// Otherwise delivery is fail-closed: Cmd+V is posted only when the runtime + /// frontmost app exactly matches the latched target and Accessibility + /// permits event posting. Every unconfirmed case becomes a tagged copy. pub async fn paste_text_from_overlay(&self, text: String) -> Result { let trimmed = text.trim(); - if trimmed.is_empty() { + let target_app = self.pre_overlay_frontmost_app.read().await.clone(); + let intent = DeliveryIntent::OverlayInsert; + let decision = resolve_delivery_route( + intent, + overlay_insert_facts( + !trimmed.is_empty(), + target_app.as_deref().is_some_and(target_is_self_app), + ), + ); + info!( + "{}", + format_delivery_route_line(intent, decision, target_app.as_deref()) + ); + if trimmed.is_empty() || decision.route == DeliveryRoute::ArchiveOnly { return Ok(OverlayPasteResult { delivery: OverlayPasteDelivery::Noop, target_app_name: None, @@ -928,8 +942,12 @@ impl RecordingController { deferred_insert_failure: None, }); } + if decision.route == DeliveryRoute::DeferredInsert { + return self + .arm_overlay_text(trimmed, target_app, Some("Codescribe".to_string())) + .await; + } - let target_app = self.pre_overlay_frontmost_app.read().await.clone(); if let Some(app_name) = target_app.as_deref() { let activated = activate_app_by_name(app_name); let focus_confirmed = @@ -1035,20 +1053,15 @@ impl RecordingController { } } - /// Arm the edited overlay transcript without attempting target activation. - /// Used when the caret is known to still be inside Codescribe. - pub async fn defer_text_from_overlay(&self, text: String) -> Result { - let trimmed = text.trim(); - if trimmed.is_empty() { - return Ok(OverlayPasteResult { - delivery: OverlayPasteDelivery::Noop, - target_app_name: None, - frontmost_app_name: None, - deferred_insert_shortcut: None, - deferred_insert_failure: None, - }); - } - let target_app = self.pre_overlay_frontmost_app.read().await.clone(); + /// Arm tagged overlay text for Paste Here (or copy if that shortcut cannot + /// register). Shared by the throne's `DeferredInsert` verdict and by the + /// explicit defer click. + async fn arm_overlay_text( + &self, + trimmed: &str, + target_app: Option, + frontmost_app_name: Option, + ) -> Result { let config = self.config.read().await.clone(); let payload = maybe_wrap_transcript_for_delivery(trimmed, &config, "dictation"); let mut deferred_insert_shortcut = None; @@ -1062,12 +1075,39 @@ impl RecordingController { Ok(OverlayPasteResult { delivery, target_app_name: target_app, - frontmost_app_name: Some("Codescribe".to_string()), + frontmost_app_name, deferred_insert_shortcut, deferred_insert_failure, }) } + /// Arm the edited overlay transcript without attempting target activation. + /// Used when the caret is known to still be inside Codescribe. + pub async fn defer_text_from_overlay(&self, text: String) -> Result { + let trimmed = text.trim(); + let target_app = self.pre_overlay_frontmost_app.read().await.clone(); + let intent = DeliveryIntent::OverlayInsert; + // This entry exists because Swift already knows the caret is inside + // Codescribe. That is a latched-self fact, not a focus-at-click fact. + let decision = + resolve_delivery_route(intent, overlay_insert_facts(!trimmed.is_empty(), true)); + info!( + "{}", + format_delivery_route_line(intent, decision, target_app.as_deref()) + ); + if trimmed.is_empty() || decision.route == DeliveryRoute::ArchiveOnly { + return Ok(OverlayPasteResult { + delivery: OverlayPasteDelivery::Noop, + target_app_name: None, + frontmost_app_name: None, + deferred_insert_shortcut: None, + deferred_insert_failure: None, + }); + } + self.arm_overlay_text(trimmed, target_app, Some("Codescribe".to_string())) + .await + } + /// Copy the tagged transcript to the clipboard without any synthetic paste. /// Degrade path for the overlay Insert action when the caret already sits /// inside Codescribe (e.g. the overlay's editable FINAL), where a synthetic diff --git a/app/controller/quality_delivery.rs b/app/controller/quality_delivery.rs index f023f518..23a76df2 100644 --- a/app/controller/quality_delivery.rs +++ b/app/controller/quality_delivery.rs @@ -138,8 +138,12 @@ fn word_sequence(text: &str) -> Vec { } /// Which gesture ended the recording that is now up for auto-paste. +/// Production stop-path now asks [`DeliveryFacts::auto_paste_enabled`]; this +/// matrix stays as the test-visible predecessor so existing cases do not +/// evaporate. +#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum AutoPasteTrigger { +pub(crate) enum AutoPasteTrigger { /// Push-to-talk hold released. Hold, /// Double-tap of the left Option key. @@ -148,8 +152,9 @@ pub(super) enum AutoPasteTrigger { /// Everything the auto-paste decision is allowed to depend on, gathered in one /// place so the policy stays a pure function of explicit state. +#[cfg(test)] #[derive(Debug, Clone, Copy)] -pub(super) struct AutoPastePolicyContext { +pub(crate) struct AutoPastePolicyContext { pub trigger: AutoPasteTrigger, pub persisted_enabled: bool, pub overlay_enabled: bool, @@ -166,7 +171,8 @@ pub(super) struct AutoPastePolicyContext { /// Every veto is independent and fail-closed: assistive sessions, no-speech and /// empty results, notes-only saves, live streaming sessions, and pending quality /// commits each suppress the paste on their own. -pub(super) fn resolve_auto_paste_policy(context: AutoPastePolicyContext) -> bool { +#[cfg(test)] +pub(crate) fn resolve_auto_paste_policy(context: AutoPastePolicyContext) -> bool { // Trigger and presentation state deliberately do not fork policy. Keeping // the explicit matrix here makes that parity reviewable and testable. let persisted_policy = match (context.trigger, context.overlay_enabled) { diff --git a/core/quality/engine_contract.rs b/core/quality/engine_contract.rs index a70ef808..72452f17 100644 --- a/core/quality/engine_contract.rs +++ b/core/quality/engine_contract.rs @@ -66,7 +66,12 @@ pub enum RelayLayer { /// Machine-readable lock. Quality HTML and corpus JSON must serialize this /// object, not a free-form paragraph an agent can paraphrase. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// Serialize-only: the lock lives as a `const` with `&'static` slices. +/// serde cannot `Deserialize` those borrows, and nothing in the tree +/// reads this type back from JSON — the compile-time constant is the +/// source of truth. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct EngineContract { pub id: &'static str, pub primary_key: &'static str, diff --git a/docs/DELIVERY_ROUTE.md b/docs/DELIVERY_ROUTE.md index cbe7bf4a..02bbf6eb 100644 --- a/docs/DELIVERY_ROUTE.md +++ b/docs/DELIVERY_ROUTE.md @@ -24,15 +24,22 @@ | `OverlayToAgent` | overlay **To Agent** | `AgentComposer` | | `OrientDictation` | Hold Fn / Globe | `ClipboardPaste` if auto-paste + latched foreign app; else `OrientCanvas` | | `OrientFormat` | Double Left Option | same as dictation | -| `OverlayInsert` | overlay Insert | `ClipboardPaste` (fail-closed to copy / Paste Here) | +| `OverlayInsert` | overlay Insert / defer | `ClipboardPaste` if the latched target is a foreign app; `DeferredInsert` if the latched target (or the caret) is Codescribe | | `NotesOnly` | save-only notes | `ArchiveOnly` | Vetoes that keep Orient off the paste gun: empty / no-speech, live-stream session, quality-commit pending, latched target is Codescribe. +Explicit overlay clicks do **not** inherit the live-stream or quality-commit +vetoes. The user asked to insert now. Codescribe as the latched target still +refuses Cmd+V into ourselves and arms Paste Here instead. + +`paste_text_from_overlay` and `defer_text_from_overlay` consult +`resolve_delivery_route`. They do not pick a destination on their own. + ## Telemetry -One INFO line per stop / To Agent: +One INFO line per stop / To Agent / overlay Insert / defer: ```text delivery_route: intent=orient_dictation route=clipboard_paste reason=auto_paste_to_latched_target target=Ghostty