← Commit history

v1.9.25 — file input first-class (@file + bare-path detection); never speak a path aloud; input-lint rule in skills

John Lauer ·254d54dfd1 ·3mo ago ·parent bfac393
5 files changed +78−25
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.24"+version = "1.9.25" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.24+1.9.25
dev-skills/adom-tts-dev/SKILL.md+10
@@ -118,6 +118,16 @@ threads) and gets confused about WHO said WHAT. Two mechanisms, keep both:   human-facing surface, carry label + desc through it (queue entry → sidecar →   CurrentClip → /poll → UI). +### 7. Lint the AI's input — never speak garbage aloud+AIs pass what feels natural: `@/path/file.txt` (curl-style), bare paths,+stdin. Meet them where they are — `@file` and bare-existing-path are+first-class file inputs (contents are read + a notice teaches the explicit+form) — but a path that does NOT exist, a binary file, or a >200 KB file is+a HARD ERROR with a teaching hint. The human must never hear "slash tmp+slash claude" read out character by character (happened live: a thread sent+@-paths and the human heard the path twice). Same principle for any future+input: detect the intent mismatch at the CLI boundary, teach in the error.+ ## Debugging playbook  - Start: `adom-tts logs` (verdict summary + anomaly hints) and `adom-tts status`
page.json−21
@@ -12,7 +12,6 @@     "audio",     "demo",     "accessibility",-    "cli",     "open-source",     "mit"   ],@@ -65,25 +64,5 @@   ],   "install": {     "binary_name": "adom-tts"-  },-  "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "needs_sudo": false,-  "files": [-    "package.json",-    "install.sh",-    "uninstall.sh",-    "bin/adom-tts",-    "skills/**",-    "completions/**"-  ],-  "org": "adom",-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"   } }\ No newline at end of file
src/main.rs+66−2
@@ -65,7 +65,10 @@ enum Cmd {     /// to pre-phoneticize Adom terms like "adom-tsci". Pass     /// `--no-pronunciations` for raw-text synthesis.     Say {-        /// Text to synthesize. Use `-` to read stdin.+        /// Text to synthesize — a string, `-` for stdin, or `@/path/file.txt`+        /// to speak an existing text file's CONTENTS. A bare path that exists+        /// is also read (with a notice); a path that doesn't exist is an+        /// error — the path itself is never spoken aloud.         text: String,         #[arg(long)]         out: PathBuf,@@ -460,6 +463,67 @@ fn split_into_chunks(text: &str, max: usize) -> Vec<String> { }  #[allow(clippy::too_many_arguments)]+/// FILE INPUT is a first-class citizen — and speaking a raw path aloud is a+/// hard error. AIs keep passing files expecting the CLI to read them (a+/// thread once sent `@/tmp/.../colby-message.txt` and the human heard the+/// PATH read out, twice). Rules:+/// - `@/path/file.txt`  -> explicit file input: read the contents (error if+///   missing/binary/huge).+/// - a bare arg that LOOKS like a path (no whitespace, starts with / ./ ~/,+///   plausible length) and EXISTS -> read it, with a notice teaching the+///   explicit `@` form.+/// - looks like a path but does NOT exist -> refuse with a teaching hint+///   (never speak "slash tmp slash ..." to a human).+fn resolve_text_input(text: &str) -> Result<String> {+    const MAX_BYTES: u64 = 200_000;+    let read_file = |path: &str| -> Result<String> {+        let expanded = if let Some(rest) = path.strip_prefix("~/") {+            format!("{}/{rest}", std::env::var("HOME").unwrap_or_default())+        } else { path.to_string() };+        let p = std::path::Path::new(&expanded);+        if !p.is_file() {+            eprintln!("adom-tts: \"{path}\" is not an existing file.");+            eprintln!("# hint: to speak a FILE's contents pass `@/abs/path.txt` (file must exist), pipe it");+            eprintln!("#       (`cat file | adom-tts say -`), or pass the text itself as a normal string.");+            std::process::exit(1);+        }+        let meta = std::fs::metadata(p)?;+        if meta.len() > MAX_BYTES {+            eprintln!("adom-tts: {path} is {} KB — too large to narrate in one clip (max {} KB).", meta.len()/1024, MAX_BYTES/1024);+            eprintln!("# hint: split it, or summarize it and speak the summary. Long text auto-chunks fine,");+            eprintln!("#       but a 200 KB read is ~4 hours of audio — almost certainly not what you meant.");+            std::process::exit(1);+        }+        let bytes = std::fs::read(p)?;+        match String::from_utf8(bytes) {+            Ok(s) => Ok(s),+            Err(_) => {+                eprintln!("adom-tts: {path} is not UTF-8 text (binary file?) — refusing to narrate it.");+                eprintln!("# hint: pass a text file, or the text itself as a string.");+                std::process::exit(1);+            }+        }+    };+    // Explicit @file syntax (curl-style) — always file input.+    if let Some(path) = text.strip_prefix('@') {+        let s = read_file(path)?;+        eprintln!("# read {} chars from {path} — speaking the file's CONTENTS.", s.chars().count());+        return Ok(s);+    }+    // Bare path detection: a "sentence" with no whitespace that starts like a+    // path is never something a human should hear read out character by character.+    let path_like = !text.contains(char::is_whitespace)+        && (text.starts_with('/') || text.starts_with("./") || text.starts_with("~/"))+        && text.len() < 300;+    if path_like {+        let s = read_file(text)?;   // exits with the teaching hint if missing+        eprintln!("# \"{text}\" is a file path — read {} chars and speaking the CONTENTS, not the path.", s.chars().count());+        eprintln!("# hint: this worked, but be explicit next time: `adom-tts say @{text} ...`");+        return Ok(s);+    }+    Ok(text.to_string())+}+ fn cmd_say(     text: &str,     out: &PathBuf,@@ -493,7 +557,7 @@ fn cmd_say(         std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;         s     } else {-        text.to_string()+        resolve_text_input(text)?     };     if text.trim().is_empty() {         return Err(anyhow!("empty text — pass a string or '-' for stdin"));