Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d15e944
feat: add structured URL rewrite handling
OneNoted Mar 20, 2026
c53ff0e
feat: add dictation runtime stage tracking
OneNoted Mar 21, 2026
e88c978
feat: add whisper_cpp hang watchdog
OneNoted Mar 21, 2026
6294582
docs: add hang diagnostics troubleshooting
OneNoted Mar 21, 2026
90386a4
fix: bound local rewrite request timeouts
OneNoted Mar 21, 2026
88cf60e
fix: preserve structured literal URL punctuation
OneNoted Mar 21, 2026
1a3ff3b
fix: bound rewrite planning hangs
OneNoted Mar 21, 2026
8241c79
chore: local notes
OneNoted Mar 21, 2026
02115a8
fix: harden runtime hang guards
OneNoted Mar 22, 2026
c52d473
fix: stabilize wl-copy timeout test
OneNoted Mar 22, 2026
2ffe148
fix: address PR review feedback
OneNoted Mar 22, 2026
60e404d
fix: format structured text parser
OneNoted Mar 22, 2026
71e00d8
fix: preserve structured literal candidate kind
OneNoted Mar 22, 2026
7962d6c
fix: kill timed command descendants
OneNoted Mar 22, 2026
dc31682
fix: avoid detached timeout saturation
OneNoted Mar 22, 2026
efac073
fix: satisfy clippy for timeout guards
OneNoted Mar 22, 2026
1d45c41
fix: stabilize ASR cleanup tests
OneNoted Mar 22, 2026
471508f
fix: bound command output drain
OneNoted Mar 22, 2026
f2a289b
fix: guard runtime diagnostics writes
OneNoted Mar 22, 2026
078cfd3
fix: recover poisoned env test lock
OneNoted Mar 22, 2026
dbe1850
fix: stabilize watchdog transition test
OneNoted Mar 22, 2026
bed17db
fix: harden safe file symlink handling
OneNoted Mar 22, 2026
7d460f4
fix: preserve prefixed structured literals
OneNoted Mar 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ whispers completions zsh
- `whispers` installs the helper rewrite worker for you when that feature is enabled.
- Shell completions are printed to `stdout`.

## Troubleshooting

If the main `whispers` process ever gets stuck after playback when using local
`whisper_cpp`, enable the built-in hang diagnostics for the next repro:

```sh
WHISPERS_HANG_DEBUG=1 whispers
```

When that mode is enabled, `whispers` writes runtime status and hang bundles
under `${XDG_RUNTIME_DIR:-/tmp}/whispers/`:

- `main-status.json` shows the current dictation stage and recent stage metadata.
- `hang-<pid>-<stage>-<timestamp>.log` is emitted if `whisper_cpp` spends too
long in model load or transcription.

Those bundles include the current status snapshot plus best-effort stack and
open-file diagnostics. If the hang reproduces, capture the newest `hang-*.log`
file along with `main-status.json`.

## License

[Mozilla Public License 2.0](LICENSE)
42 changes: 37 additions & 5 deletions src/agentic_rewrite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ struct PreparedGlossaryEntry {
normalized_aliases: Vec<Vec<String>>,
}

#[derive(Debug, Clone, Default)]
pub(crate) struct RuntimePolicyResources {
policy_rules: Vec<AppRule>,
glossary_entries: Vec<GlossaryEntry>,
}

pub use runtime::conservative_output_allowed;

pub fn default_policy_path() -> &'static str {
Expand All @@ -56,17 +62,43 @@ pub fn default_glossary_path() -> &'static str {
store::default_glossary_path()
}

pub(crate) fn load_runtime_resources(config: &Config) -> RuntimePolicyResources {
load_runtime_resources_with_status(config).0
}

pub(crate) fn load_runtime_resources_with_status(
config: &Config,
) -> (RuntimePolicyResources, bool) {
let (policy_rules, policy_degraded) =
store::load_policy_file_for_runtime_with_status(&config.resolved_rewrite_policy_path());
let (glossary_entries, glossary_degraded) =
store::load_glossary_file_for_runtime_with_status(&config.resolved_rewrite_glossary_path());

(
RuntimePolicyResources {
policy_rules,
glossary_entries,
},
policy_degraded || glossary_degraded,
)
}

pub fn apply_runtime_policy(config: &Config, transcript: &mut RewriteTranscript) {
let policy_rules = store::load_policy_file_for_runtime(&config.resolved_rewrite_policy_path());
let glossary_entries =
store::load_glossary_file_for_runtime(&config.resolved_rewrite_glossary_path());
let resources = load_runtime_resources(config);
apply_runtime_policy_with_resources(config, transcript, &resources);
}

pub(crate) fn apply_runtime_policy_with_resources(
config: &Config,
transcript: &mut RewriteTranscript,
resources: &RuntimePolicyResources,
) {
let policy_context = runtime::resolve_policy_context(
config.rewrite.default_correction_policy,
transcript.typing_context.as_ref(),
&transcript.rewrite_candidates,
&policy_rules,
&glossary_entries,
&resources.policy_rules,
&resources.glossary_entries,
);

for candidate in &policy_context.glossary_candidates {
Expand Down
4 changes: 2 additions & 2 deletions src/agentic_rewrite/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ fn built_in_rules(default_policy: RewriteCorrectionPolicy) -> Vec<AppRule> {
surface_kind: Some(RewriteSurfaceKind::Browser),
..ContextMatcher::default()
},
"Favor clean prose and natural punctuation for browser text fields, but stay grounded in the listed candidates, glossary evidence, and the utterance's technical topic when it clearly refers to software or documentation.",
"Favor clean prose and natural punctuation for browser text fields, except when the utterance is structured text such as a hostname, URL, email address, or similar punctuation-sensitive literal. In those cases preserve punctuation literally and do not rewrite it into prose. Stay grounded in the listed candidates, glossary evidence, and the utterance's technical topic when it clearly refers to software or documentation.",
Some(RewriteCorrectionPolicy::Balanced),
),
AppRule::built_in(
Expand All @@ -314,7 +314,7 @@ fn built_in_rules(default_policy: RewriteCorrectionPolicy) -> Vec<AppRule> {
surface_kind: Some(RewriteSurfaceKind::GenericText),
..ContextMatcher::default()
},
"Favor clean prose and natural punctuation for general text entry while staying grounded in the listed candidates and glossary evidence. If the utterance clearly discusses technical tools or software, prefer the most plausible technical term over a phonetically similar common word.",
"Favor clean prose and natural punctuation for general text entry, except when the utterance is structured text such as a hostname, URL, email address, or similar punctuation-sensitive literal. In those cases preserve punctuation literally and do not rewrite it into prose. Stay grounded in the listed candidates and glossary evidence. If the utterance clearly discusses technical tools or software, prefer the most plausible technical term over a phonetically similar common word.",
Some(RewriteCorrectionPolicy::Balanced),
),
AppRule::built_in(
Expand Down
25 changes: 14 additions & 11 deletions src/agentic_rewrite/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::error::{Result, WhsprError};
use crate::safe_fs;

use super::{AppRule, GlossaryEntry};

Expand Down Expand Up @@ -101,7 +102,7 @@ fn ensure_text_file(path: &Path, contents: &str) -> Result<bool> {
}

write_parent(path)?;
std::fs::write(path, contents).map_err(|e| {
safe_fs::write(path, contents).map_err(|e| {
WhsprError::Config(format!(
"failed to write starter file {}: {e}",
path.display()
Expand All @@ -115,7 +116,7 @@ pub(super) fn read_policy_file(path: &Path) -> Result<Vec<AppRule>> {
return Ok(Vec::new());
}

let contents = std::fs::read_to_string(path).map_err(|e| {
let contents = safe_fs::read_to_string(path).map_err(|e| {
WhsprError::Config(format!("failed to read app rules {}: {e}", path.display()))
})?;
if contents.trim().is_empty() {
Expand All @@ -133,7 +134,7 @@ pub(super) fn write_policy_file(path: &Path, rules: &[AppRule]) -> Result<()> {
rules: rules.to_vec(),
})
.map_err(|e| WhsprError::Config(format!("failed to encode app rules: {e}")))?;
std::fs::write(path, contents).map_err(|e| {
safe_fs::write(path, contents).map_err(|e| {
WhsprError::Config(format!("failed to write app rules {}: {e}", path.display()))
})?;
Ok(())
Expand All @@ -144,7 +145,7 @@ pub(super) fn read_glossary_file(path: &Path) -> Result<Vec<GlossaryEntry>> {
return Ok(Vec::new());
}

let contents = std::fs::read_to_string(path).map_err(|e| {
let contents = safe_fs::read_to_string(path).map_err(|e| {
WhsprError::Config(format!("failed to read glossary {}: {e}", path.display()))
})?;
if contents.trim().is_empty() {
Expand All @@ -162,28 +163,30 @@ pub(super) fn write_glossary_file(path: &Path, entries: &[GlossaryEntry]) -> Res
entries: entries.to_vec(),
})
.map_err(|e| WhsprError::Config(format!("failed to encode glossary: {e}")))?;
std::fs::write(path, contents).map_err(|e| {
safe_fs::write(path, contents).map_err(|e| {
WhsprError::Config(format!("failed to write glossary {}: {e}", path.display()))
})?;
Ok(())
}

pub(super) fn load_policy_file_for_runtime(path: &Path) -> Vec<AppRule> {
pub(super) fn load_policy_file_for_runtime_with_status(path: &Path) -> (Vec<AppRule>, bool) {
match read_policy_file(path) {
Ok(rules) => rules,
Ok(rules) => (rules, false),
Err(err) => {
tracing::warn!("{err}; using built-in app rewrite defaults");
Vec::new()
(Vec::new(), true)
}
}
}

pub(super) fn load_glossary_file_for_runtime(path: &Path) -> Vec<GlossaryEntry> {
pub(super) fn load_glossary_file_for_runtime_with_status(
path: &Path,
) -> (Vec<GlossaryEntry>, bool) {
match read_glossary_file(path) {
Ok(entries) => entries,
Ok(entries) => (entries, false),
Err(err) => {
tracing::warn!("{err}; ignoring runtime glossary");
Vec::new()
(Vec::new(), true)
}
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ use std::time::Instant;
use crate::config::Config;
use crate::error::Result;
use crate::postprocess::finalize;
use crate::runtime_diagnostics::DictationRuntimeDiagnostics;
use crate::runtime_support::PidLock;

mod osd;
mod runtime;

use runtime::DictationRuntime;

pub async fn run(config: Config) -> Result<()> {
pub async fn run(config: Config, _pid_lock: PidLock) -> Result<()> {
let activation_started = Instant::now();
let diagnostics = DictationRuntimeDiagnostics::new(&config);
// Register signals before startup work to minimize early-signal races.
let mut sigusr1 =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::user_defined1())?;
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut runtime = DictationRuntime::new(config);
let mut runtime = DictationRuntime::new(config, diagnostics.clone());
let recording = runtime.start_recording()?;
runtime.prepare_services()?;

Expand All @@ -41,6 +44,7 @@ pub async fn run(config: Config) -> Result<()> {
if transcribed.is_empty() {
tracing::warn!("transcription returned empty text");
finalize::wait_for_feedback_drain().await;
diagnostics.clear_with_stage(crate::runtime_diagnostics::DictationStage::Done);
return Ok(());
}

Expand All @@ -53,10 +57,12 @@ pub async fn run(config: Config) -> Result<()> {
// causes an audible click as the OS closes our audio file descriptors.
// With speech, transcription takes seconds — providing natural drain time.
finalize::wait_for_feedback_drain().await;
diagnostics.clear_with_stage(crate::runtime_diagnostics::DictationStage::Done);
return Ok(());
}

runtime.inject_finalized(finalized).await?;
diagnostics.clear_with_stage(crate::runtime_diagnostics::DictationStage::Done);

tracing::info!("done");
tracing::info!(
Expand Down
22 changes: 20 additions & 2 deletions src/app/osd.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
use std::process::Child;
use std::time::Duration;

#[cfg(feature = "osd")]
use std::process::Command;

use crate::runtime_guards::wait_child_with_timeout;

const OSD_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250);

#[cfg(feature = "osd")]
pub(super) fn spawn_osd() -> Option<Child> {
// Look for whispers-osd next to our own binary first, then fall back to PATH
Expand Down Expand Up @@ -38,8 +43,21 @@ pub(super) fn kill_osd(child: &mut Option<Child>) {
unsafe {
libc::kill(pid, libc::SIGTERM);
}
let _ = c.wait();
tracing::debug!("whispers-osd (pid {pid}) terminated");
match wait_child_with_timeout(&mut c, OSD_SHUTDOWN_TIMEOUT) {
Ok(Some(_)) => {
tracing::debug!("whispers-osd (pid {pid}) terminated");
}
Ok(None) => {
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let _ = wait_child_with_timeout(&mut c, OSD_SHUTDOWN_TIMEOUT);
tracing::warn!("whispers-osd (pid {pid}) did not exit after SIGTERM; killed");
}
Err(err) => {
tracing::warn!("failed to wait for whispers-osd (pid {pid}) to exit: {err}");
}
}
}
}

Expand Down
Loading