# Voice & Video-Note Transcription Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Recognize Telegram voice messages and circular video notes on the host, substitute transcripts into the agent payload as Russian-language markers, fail soft when ffmpeg or model is missing.

**Architecture:** Host-side STT in the `rightclaw-bot` crate using `whisper-rs` (Rust binding to whisper.cpp). System `ffmpeg` decodes OGG/MP4 to PCM via subprocess + stdout pipe. Models live in `~/.rightclaw/cache/whisper/`, downloaded by `rightclaw up` only when ffmpeg is present. A new `Transcriber` is constructed at bot startup, plumbed into `download_attachments` which transcribes voice/video-note instead of uploading them to the sandbox.

**Tech Stack:** Rust 2024, `whisper-rs` (Metal feature on macOS), system `ffmpeg`, `reqwest` (already in workspace) for model download, `tokio` for async/spawn_blocking.

---

## File Structure

**Created:**
- `crates/bot/src/stt/mod.rs` — `Transcriber`, `TranscriptionResult`, `SttError`, `SttContext`, `transcribe_attachment` glue.
- `crates/bot/src/stt/decode.rs` — ffmpeg subprocess wrapper (`decode_to_pcm_f32`).
- `crates/bot/src/stt/whisper.rs` — `WhisperEngine` lazy holder over `WhisperContext`.
- `crates/bot/src/stt/markers.rs` — Russian marker constants + formatting helpers.
- `crates/bot/tests/fixtures/hello.oga` — short OGG/Opus voice with known content.
- `crates/bot/tests/fixtures/circle.mp4` — short MP4 video-note with known content.
- `crates/rightclaw/src/stt.rs` — shared utilities: `model_cache_path`, `ffmpeg_available`, `download_model`, `ensure_models_cached`.

**Modified:**
- `crates/rightclaw/src/agent/types.rs` — add `SttConfig`, `WhisperModel`, extend `AgentConfig`.
- `crates/rightclaw/src/lib.rs` — declare `pub mod stt`.
- `crates/bot/Cargo.toml` — add `whisper-rs` dependency.
- `crates/bot/src/lib.rs` — declare `mod stt;`, build `SttContext` at bot startup, pass into worker context.
- `crates/bot/src/telegram/worker.rs` — accept `Option<Arc<SttContext>>` from `WorkerContext`, pass into `download_attachments`, merge returned voice markers into `InputMessage.text`.
- `crates/bot/src/telegram/attachments.rs` — `download_attachments` accepts `Option<&SttContext>` and returns `(Vec<ResolvedAttachment>, Vec<String>)`; for voice/video_note when STT context is present, transcribe instead of uploading to sandbox.
- `crates/rightclaw-cli/src/main.rs` (or wherever `cmd_up` lives) — call `ensure_models_cached` after agent discovery.
- `crates/rightclaw/src/doctor.rs` — add ffmpeg + model checks.
- `ARCHITECTURE.md` — document new module + data-flow change.

---

## Task 1: Config types — `SttConfig` and `WhisperModel`

**Files:**
- Modify: `crates/rightclaw/src/agent/types.rs`

- [ ] **Step 1: Write failing tests**

Append to existing test module in `crates/rightclaw/src/agent/types.rs` (or create one):

```rust
#[cfg(test)]
mod stt_config_tests {
    use super::*;

    #[test]
    fn stt_config_defaults_when_missing() {
        let yaml = "name: test\n";
        let cfg: AgentConfig = serde_saphyr::from_str(yaml).unwrap();
        assert!(cfg.stt.enabled);
        assert_eq!(cfg.stt.model, WhisperModel::Small);
    }

    #[test]
    fn stt_config_explicit_yaml_roundtrip() {
        let yaml = "\
name: test
stt:
  enabled: false
  model: tiny
";
        let cfg: AgentConfig = serde_saphyr::from_str(yaml).unwrap();
        assert!(!cfg.stt.enabled);
        assert_eq!(cfg.stt.model, WhisperModel::Tiny);
    }

    #[test]
    fn stt_config_large_v3_kebab_case() {
        let yaml = "\
name: test
stt:
  model: large-v3
";
        let cfg: AgentConfig = serde_saphyr::from_str(yaml).unwrap();
        assert_eq!(cfg.stt.model, WhisperModel::LargeV3);
    }

    #[test]
    fn stt_config_invalid_model_errors() {
        let yaml = "\
name: test
stt:
  model: huge
";
        let result: Result<AgentConfig, _> = serde_saphyr::from_str(yaml);
        assert!(result.is_err());
    }
}
```

- [ ] **Step 2: Run tests to confirm failure**

```bash
cargo test -p rightclaw stt_config_ -- --nocapture
```
Expected: compilation error (missing types).

- [ ] **Step 3: Add types to `crates/rightclaw/src/agent/types.rs`**

```rust
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct SttConfig {
    #[serde(default = "default_true_stt")]
    pub enabled: bool,
    #[serde(default)]
    pub model: WhisperModel,
}

impl Default for SttConfig {
    fn default() -> Self {
        Self { enabled: true, model: WhisperModel::default() }
    }
}

fn default_true_stt() -> bool { true }

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum WhisperModel {
    Tiny,
    Base,
    #[default]
    Small,
    Medium,
    #[serde(rename = "large-v3")]
    LargeV3,
}
```

In `AgentConfig`:

```rust
pub struct AgentConfig {
    // ... existing fields ...
    #[serde(default)]
    pub stt: SttConfig,
}
```

- [ ] **Step 4: Run tests to confirm pass**

```bash
cargo test -p rightclaw stt_config_
```
Expected: 4 tests pass.

- [ ] **Step 5: Commit**

```bash
git add crates/rightclaw/src/agent/types.rs
git commit -m "feat(stt): add SttConfig and WhisperModel to agent config"
```

---

## Task 2: WhisperModel pure methods (filename, URL, size)

**Files:**
- Modify: `crates/rightclaw/src/agent/types.rs`

- [ ] **Step 1: Write failing tests**

Append to `stt_config_tests`:

```rust
#[test]
fn whisper_model_filename() {
    assert_eq!(WhisperModel::Tiny.filename(), "ggml-tiny.bin");
    assert_eq!(WhisperModel::Base.filename(), "ggml-base.bin");
    assert_eq!(WhisperModel::Small.filename(), "ggml-small.bin");
    assert_eq!(WhisperModel::Medium.filename(), "ggml-medium.bin");
    assert_eq!(WhisperModel::LargeV3.filename(), "ggml-large-v3.bin");
}

#[test]
fn whisper_model_download_url_is_huggingface() {
    let url = WhisperModel::Small.download_url();
    assert!(url.starts_with("https://huggingface.co/ggerganov/whisper.cpp/"));
    assert!(url.ends_with("ggml-small.bin"));
}

#[test]
fn whisper_model_approx_size_mb_is_sane() {
    assert!(WhisperModel::Tiny.approx_size_mb() < 100);
    assert!(WhisperModel::Small.approx_size_mb() < 600);
    assert!(WhisperModel::LargeV3.approx_size_mb() > 2000);
}
```

- [ ] **Step 2: Run tests to confirm failure**

```bash
cargo test -p rightclaw whisper_model_
```
Expected: compile error (methods missing).

- [ ] **Step 3: Implement methods on `WhisperModel`**

```rust
impl WhisperModel {
    pub fn filename(&self) -> &'static str {
        match self {
            Self::Tiny => "ggml-tiny.bin",
            Self::Base => "ggml-base.bin",
            Self::Small => "ggml-small.bin",
            Self::Medium => "ggml-medium.bin",
            Self::LargeV3 => "ggml-large-v3.bin",
        }
    }

    pub fn download_url(&self) -> &'static str {
        match self {
            Self::Tiny =>    "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
            Self::Base =>    "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
            Self::Small =>   "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
            Self::Medium =>  "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin",
            Self::LargeV3 => "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin",
        }
    }

    pub fn approx_size_mb(&self) -> u64 {
        match self {
            Self::Tiny => 75,
            Self::Base => 150,
            Self::Small => 470,
            Self::Medium => 1500,
            Self::LargeV3 => 3100,
        }
    }
}
```

- [ ] **Step 4: Run tests**

```bash
cargo test -p rightclaw whisper_model_
```
Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add crates/rightclaw/src/agent/types.rs
git commit -m "feat(stt): add filename/url/size methods to WhisperModel"
```

---

## Task 3: `rightclaw::stt` module — paths and ffmpeg check

**Files:**
- Create: `crates/rightclaw/src/stt.rs`
- Modify: `crates/rightclaw/src/lib.rs`

- [ ] **Step 1: Write failing tests**

Create `crates/rightclaw/src/stt.rs`:

```rust
//! Shared STT helpers used by both `rightclaw up` (model download) and
//! `rightclaw-bot` (transcription). Crate root: see `Transcriber` in the bot
//! crate for actual inference.

use std::path::{Path, PathBuf};

use crate::agent::types::WhisperModel;

/// Returns the cache path for a whisper model under the given RIGHTCLAW_HOME.
/// Layout: `<home>/cache/whisper/ggml-<model>.bin`.
pub fn model_cache_path(home: &Path, model: WhisperModel) -> PathBuf {
    home.join("cache").join("whisper").join(model.filename())
}

/// Returns true if `ffmpeg` is on PATH.
pub fn ffmpeg_available() -> bool {
    which::which("ffmpeg").is_ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn model_cache_path_layout() {
        let home = Path::new("/tmp/.rightclaw");
        let p = model_cache_path(home, WhisperModel::Small);
        assert_eq!(p, Path::new("/tmp/.rightclaw/cache/whisper/ggml-small.bin"));
    }

    #[test]
    fn ffmpeg_available_returns_a_bool() {
        // We don't assert the value — depends on the dev machine. We do
        // assert it doesn't panic and returns a bool.
        let _ = ffmpeg_available();
    }
}
```

In `crates/rightclaw/src/lib.rs`, add (alphabetical with existing modules):

```rust
pub mod stt;
```

- [ ] **Step 2: Run tests**

```bash
cargo test -p rightclaw stt::
```
Expected: 2 tests pass.

- [ ] **Step 3: Commit**

```bash
git add crates/rightclaw/src/stt.rs crates/rightclaw/src/lib.rs
git commit -m "feat(stt): add model_cache_path and ffmpeg_available helpers"
```

---

## Task 4: `download_model` with atomic rename

**Files:**
- Modify: `crates/rightclaw/src/stt.rs`

- [ ] **Step 1: Write failing tests**

Append to `tests` mod in `stt.rs`:

```rust
use tempfile::TempDir;

#[tokio::test]
async fn download_model_writes_to_partial_then_renames() {
    // Use a tiny URL we control via a wiremock-style stub. Without a stub
    // available, exercise the atomic-rename path with a fake download
    // closure (extracted in Step 3 below).
    let tmp = TempDir::new().unwrap();
    let dest = tmp.path().join("ggml-tiny.bin");
    let partial = tmp.path().join("ggml-tiny.bin.partial");

    // Simulate: the download writes 16 bytes to partial then renames.
    write_then_rename(&partial, &dest, b"sixteen-byte-msg").await.unwrap();

    assert!(dest.exists(), "final file should exist");
    assert!(!partial.exists(), "partial should be removed after rename");
    assert_eq!(tokio::fs::read(&dest).await.unwrap(), b"sixteen-byte-msg");
}

#[tokio::test]
async fn partial_file_is_ignored_by_cache_check() {
    let tmp = TempDir::new().unwrap();
    let dest = tmp.path().join("ggml-tiny.bin");
    let partial = tmp.path().join("ggml-tiny.bin.partial");
    tokio::fs::write(&partial, b"junk").await.unwrap();

    assert!(!is_model_cached(&dest), "partial alone is not a cache hit");

    tokio::fs::write(&dest, b"complete").await.unwrap();
    assert!(is_model_cached(&dest), "final file is a cache hit");
}
```

- [ ] **Step 2: Run tests to confirm failure**

```bash
cargo test -p rightclaw stt::
```
Expected: compile errors for `write_then_rename` and `is_model_cached`.

- [ ] **Step 3: Implement download with atomic rename**

Add to `crates/rightclaw/src/stt.rs`:

```rust
use std::io;
use thiserror::Error;
use tokio::io::AsyncWriteExt;

#[derive(Debug, Error)]
pub enum DownloadError {
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("IO error: {0}")]
    Io(#[from] io::Error),
    #[error("HTTP status {status} for {url}")]
    BadStatus { status: u16, url: String },
}

/// Returns true if the final cache file exists. `*.partial` files are ignored.
pub fn is_model_cached(dest: &Path) -> bool {
    dest.exists()
}

/// Internal helper used both by `download_model` and tests: write `bytes` to
/// `partial`, then atomically rename to `dest`.
pub(crate) async fn write_then_rename(
    partial: &Path,
    dest: &Path,
    bytes: &[u8],
) -> io::Result<()> {
    if let Some(parent) = partial.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }
    let mut f = tokio::fs::File::create(partial).await?;
    f.write_all(bytes).await?;
    f.flush().await?;
    drop(f);
    tokio::fs::rename(partial, dest).await
}

/// Download a whisper model file to `<dest>`. Streams to `<dest>.partial`,
/// renames atomically on success. On failure the partial may remain — next
/// call overwrites it.
pub async fn download_model(
    model: WhisperModel,
    dest: &Path,
) -> Result<(), DownloadError> {
    let url = model.download_url();
    let partial = dest.with_extension("bin.partial");

    if let Some(parent) = dest.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }

    let resp = reqwest::Client::new().get(url).send().await?;
    if !resp.status().is_success() {
        return Err(DownloadError::BadStatus {
            status: resp.status().as_u16(),
            url: url.to_string(),
        });
    }

    let total = resp.content_length();
    let mut downloaded: u64 = 0;
    let mut last_log_pct: u32 = 0;

    let mut file = tokio::fs::File::create(&partial).await?;
    let mut stream = resp.bytes_stream();
    use futures::StreamExt;
    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        file.write_all(&chunk).await?;
        downloaded += chunk.len() as u64;
        if let Some(t) = total {
            let pct = ((downloaded * 100) / t) as u32;
            if pct >= last_log_pct + 5 {
                last_log_pct = pct;
                eprintln!(
                    "  {} {pct}% ({:.1}/{:.1} MB)",
                    model.filename(),
                    downloaded as f64 / (1024.0 * 1024.0),
                    t as f64 / (1024.0 * 1024.0),
                );
            }
        }
    }
    file.flush().await?;
    drop(file);
    tokio::fs::rename(&partial, dest).await?;
    Ok(())
}
```

Add deps to `crates/rightclaw/Cargo.toml` (only what's missing):

```toml
futures = "0.3"
tempfile = { workspace = true } # if not already in dev-dependencies
```

(`reqwest`, `tokio`, `thiserror`, `which` should already be present per the architecture stack table; if not, add them with `cargo add`.)

- [ ] **Step 4: Run tests**

```bash
cargo test -p rightclaw stt::
```
Expected: 4 tests pass.

- [ ] **Step 5: Commit**

```bash
git add crates/rightclaw/src/stt.rs crates/rightclaw/Cargo.toml Cargo.lock
git commit -m "feat(stt): add download_model with atomic rename"
```

---

## Task 5: `ensure_models_cached`

**Files:**
- Modify: `crates/rightclaw/src/stt.rs`

- [ ] **Step 1: Write failing tests**

Append:

```rust
use std::collections::HashSet;

#[tokio::test]
async fn ensure_models_cached_skips_when_ffmpeg_missing() {
    let tmp = TempDir::new().unwrap();
    let mut models = HashSet::new();
    models.insert(WhisperModel::Small);

    // Simulate ffmpeg-missing by passing the bool explicitly
    // (the public function does the check internally; tests use the
    // bool-injected variant).
    let downloaded = ensure_models_cached_inner(
        tmp.path(), &models, /* ffmpeg_present= */ false,
    ).await.unwrap();

    assert_eq!(downloaded, 0);
    assert!(!model_cache_path(tmp.path(), WhisperModel::Small).exists());
}

#[tokio::test]
async fn ensure_models_cached_skips_already_cached() {
    let tmp = TempDir::new().unwrap();
    let mut models = HashSet::new();
    models.insert(WhisperModel::Small);

    // Pre-populate cache
    let p = model_cache_path(tmp.path(), WhisperModel::Small);
    tokio::fs::create_dir_all(p.parent().unwrap()).await.unwrap();
    tokio::fs::write(&p, b"already-cached").await.unwrap();

    let downloaded = ensure_models_cached_inner(
        tmp.path(), &models, /* ffmpeg_present= */ true,
    ).await.unwrap();

    assert_eq!(downloaded, 0);
}
```

- [ ] **Step 2: Run tests to confirm failure**

```bash
cargo test -p rightclaw stt::tests::ensure_models_cached_
```
Expected: compile errors.

- [ ] **Step 3: Implement**

Append to `crates/rightclaw/src/stt.rs`:

```rust
use std::collections::HashSet;

/// Public entry: check ffmpeg, then download missing models. Logs warnings
/// (does not error) if download fails — callers should not abort `up`.
pub async fn ensure_models_cached(
    home: &Path,
    models: &HashSet<WhisperModel>,
) -> Result<usize, DownloadError> {
    ensure_models_cached_inner(home, models, ffmpeg_available()).await
}

pub(crate) async fn ensure_models_cached_inner(
    home: &Path,
    models: &HashSet<WhisperModel>,
    ffmpeg_present: bool,
) -> Result<usize, DownloadError> {
    if !ffmpeg_present {
        eprintln!(
            "  ffmpeg not found in PATH — voice transcription disabled. \
             Install: brew install ffmpeg / apt install ffmpeg. \
             Skipping whisper model download."
        );
        return Ok(0);
    }
    let mut downloaded = 0;
    for model in models {
        let dest = model_cache_path(home, *model);
        if is_model_cached(&dest) {
            continue;
        }
        eprintln!(
            "  downloading {} (~{} MB)...",
            model.filename(),
            model.approx_size_mb()
        );
        if let Err(e) = download_model(*model, &dest).await {
            eprintln!("  WARN: download of {} failed: {e}", model.filename());
            continue;
        }
        downloaded += 1;
    }
    Ok(downloaded)
}
```

- [ ] **Step 4: Run tests**

```bash
cargo test -p rightclaw stt::
```
Expected: 6 tests pass.

- [ ] **Step 5: Commit**

```bash
git add crates/rightclaw/src/stt.rs
git commit -m "feat(stt): add ensure_models_cached for rightclaw up integration"
```

---

## Task 6: Add `whisper-rs` dependency

**Files:**
- Modify: `crates/bot/Cargo.toml`

- [ ] **Step 1: Look up the latest whisper-rs version**

```bash
cargo search whisper-rs --limit 1
```
Note the latest version (e.g. `0.13.x` at the time of writing).

- [ ] **Step 2: Add dependency**

In `crates/bot/Cargo.toml`, under `[dependencies]`:

```toml
whisper-rs = { version = "0.13", default-features = false }

[target.'cfg(target_os = "macos")'.dependencies]
whisper-rs = { version = "0.13", default-features = false, features = ["metal"] }
```

(Replace `0.13` with the version from Step 1. Keep base entry above so non-macOS builds also pull the crate; the per-target entry layers on the `metal` feature.)

- [ ] **Step 3: Confirm it compiles**

```bash
cargo build -p rightclaw-bot
```
Expected: clean build (whisper.cpp will compile from sys-crate; takes ~30–60s the first time).

If the build fails on macOS asking for Xcode CLT, that's a developer-machine prerequisite, not a code issue — surface to user.

- [ ] **Step 4: Commit**

```bash
git add crates/bot/Cargo.toml Cargo.lock
git commit -m "chore(stt): add whisper-rs dependency"
```

---

## Task 7: Test fixtures

**Files:**
- Create: `crates/bot/tests/fixtures/hello.oga`
- Create: `crates/bot/tests/fixtures/circle.mp4`

- [ ] **Step 1: Verify ffmpeg + a TTS source are available**

```bash
which ffmpeg && which say  # macOS
# Linux fallback: ensure espeak-ng or piper is installed
```

- [ ] **Step 2: Generate `hello.oga` (Telegram-style voice)**

macOS (uses `say`):

```bash
mkdir -p crates/bot/tests/fixtures
say -o /tmp/hello.aiff "this is a test"
ffmpeg -y -i /tmp/hello.aiff -ac 1 -ar 16000 -c:a libopus -b:a 16k \
  crates/bot/tests/fixtures/hello.oga
rm /tmp/hello.aiff
```

Linux fallback (uses `espeak-ng`):

```bash
mkdir -p crates/bot/tests/fixtures
espeak-ng -w /tmp/hello.wav "this is a test"
ffmpeg -y -i /tmp/hello.wav -ac 1 -ar 16000 -c:a libopus -b:a 16k \
  crates/bot/tests/fixtures/hello.oga
rm /tmp/hello.wav
```

- [ ] **Step 3: Generate `circle.mp4` (Telegram video note)**

```bash
# Reuse hello.oga as the audio track; black 240x240 video for ~2s.
ffmpeg -y \
  -f lavfi -i color=c=black:s=240x240:d=2 \
  -i crates/bot/tests/fixtures/hello.oga \
  -c:v libx264 -tune stillimage -pix_fmt yuv420p \
  -c:a aac -shortest \
  crates/bot/tests/fixtures/circle.mp4
```

- [ ] **Step 4: Verify file sizes are sane**

```bash
ls -lh crates/bot/tests/fixtures/
# Expect hello.oga ~ 5–20 KB, circle.mp4 ~ 20–80 KB.
```

- [ ] **Step 5: Commit**

```bash
git add crates/bot/tests/fixtures/hello.oga crates/bot/tests/fixtures/circle.mp4
git commit -m "test(stt): add hello.oga and circle.mp4 fixtures"
```

---

## Task 8: `bot::stt::decode` — ffmpeg subprocess wrapper

**Files:**
- Create: `crates/bot/src/stt/decode.rs`
- Modify: `crates/bot/src/lib.rs` (declare `mod stt;`)
- Create (skeleton): `crates/bot/src/stt/mod.rs`

- [ ] **Step 1: Create skeleton `mod.rs` with `SttError`**

`crates/bot/src/stt/mod.rs`:

```rust
//! Speech-to-text pipeline: ffmpeg → PCM → whisper-rs → text.

pub mod decode;
pub mod whisper;

use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum SttError {
    #[error("ffmpeg not found in PATH")]
    FfmpegNotFound,
    #[error("ffmpeg failed: {0}")]
    FfmpegFailed(String),
    #[error("whisper model file missing: {0}")]
    ModelMissing(PathBuf),
    #[error("failed to load whisper model: {0}")]
    WhisperLoadFailed(String),
    #[error("whisper inference failed: {0}")]
    WhisperInferenceFailed(String),
    #[error("audio file too large: {size_mb} MB (max {max_mb} MB)")]
    FileTooLarge { size_mb: u64, max_mb: u64 },
}

pub const MAX_AUDIO_FILE_MB: u64 = 25;
```

In `crates/bot/src/lib.rs`, near other `mod` declarations:

```rust
mod stt;
```

- [ ] **Step 2: Write failing tests for decode**

`crates/bot/src/stt/decode.rs`:

```rust
//! Wrap a system `ffmpeg` subprocess that converts an arbitrary input file to
//! PCM f32 little-endian @ 16 kHz mono, returned as `Vec<f32>`.

use crate::stt::SttError;
use std::path::Path;
use std::process::Stdio;
use tokio::io::AsyncReadExt;
use tokio::process::Command;

pub async fn decode_to_pcm_f32(input: &Path) -> Result<Vec<f32>, SttError> {
    if which::which("ffmpeg").is_err() {
        return Err(SttError::FfmpegNotFound);
    }

    let mut child = Command::new("ffmpeg")
        .arg("-nostdin")
        .arg("-loglevel").arg("error")
        .arg("-i").arg(input)
        .arg("-ar").arg("16000")
        .arg("-ac").arg("1")
        .arg("-f").arg("f32le")
        .arg("pipe:1")
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| SttError::FfmpegFailed(format!("spawn: {e}")))?;

    let mut stdout = child.stdout.take().expect("piped");
    let mut bytes = Vec::new();
    stdout.read_to_end(&mut bytes).await
        .map_err(|e| SttError::FfmpegFailed(format!("read stdout: {e}")))?;

    let status = child.wait().await
        .map_err(|e| SttError::FfmpegFailed(format!("wait: {e}")))?;
    if !status.success() {
        let mut stderr = child.stderr.take().expect("piped");
        let mut err = String::new();
        let _ = stderr.read_to_string(&mut err).await;
        let trimmed: String = err.chars().take(200).collect();
        return Err(SttError::FfmpegFailed(trimmed));
    }

    // f32 little-endian; 4 bytes per sample.
    if bytes.len() % 4 != 0 {
        return Err(SttError::FfmpegFailed(format!(
            "unaligned PCM stream: {} bytes", bytes.len()
        )));
    }
    let samples: Vec<f32> = bytes
        .chunks_exact(4)
        .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
        .collect();
    Ok(samples)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn fixture(name: &str) -> std::path::PathBuf {
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures")
            .join(name)
    }

    #[tokio::test]
    async fn decode_voice_oga_yields_samples() {
        let samples = decode_to_pcm_f32(&fixture("hello.oga")).await
            .expect("ffmpeg required (install: brew install ffmpeg / apt install ffmpeg)");
        assert!(samples.len() > 16_000, "got {} samples, want >1s @16k", samples.len());
    }

    #[tokio::test]
    async fn decode_video_note_mp4_yields_samples() {
        let samples = decode_to_pcm_f32(&fixture("circle.mp4")).await
            .expect("ffmpeg required");
        assert!(samples.len() > 16_000);
    }

    #[tokio::test]
    async fn decode_corrupted_input_returns_error() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        tokio::fs::write(tmp.path(), b"not audio").await.unwrap();
        match decode_to_pcm_f32(tmp.path()).await {
            Err(SttError::FfmpegFailed(_)) => {},
            other => panic!("expected FfmpegFailed, got {other:?}"),
        }
    }
}
```

- [ ] **Step 3: Run tests**

```bash
cargo test -p rightclaw-bot stt::decode
```
Expected: 3 tests pass.

If `tempfile` is not yet a dev-dependency of the bot crate, add it:

```bash
cargo add --package rightclaw-bot --dev tempfile
```

- [ ] **Step 4: Commit**

```bash
git add crates/bot/src/stt/ crates/bot/src/lib.rs crates/bot/Cargo.toml Cargo.lock
git commit -m "feat(stt): ffmpeg subprocess wrapper decode_to_pcm_f32"
```

---

## Task 9: `bot::stt::whisper` — `WhisperEngine`

**Files:**
- Create: `crates/bot/src/stt/whisper.rs`

- [ ] **Step 1: Test helper to ensure tiny model is cached**

Create `crates/bot/src/stt/whisper.rs`:

```rust
//! Lazy holder for a `WhisperContext`. One context per `Transcriber`,
//! shared via `Arc<Mutex<...>>` so concurrent inferences serialize.

use crate::stt::SttError;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use tokio::task;
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};

pub struct WhisperEngine {
    model_path: PathBuf,
    ctx: OnceLock<Arc<Mutex<WhisperContext>>>,
}

impl WhisperEngine {
    pub fn new(model_path: PathBuf) -> Self {
        Self { model_path, ctx: OnceLock::new() }
    }

    fn ensure_ctx(&self) -> Result<Arc<Mutex<WhisperContext>>, SttError> {
        if let Some(c) = self.ctx.get() {
            return Ok(c.clone());
        }
        if !self.model_path.exists() {
            return Err(SttError::ModelMissing(self.model_path.clone()));
        }
        let path_str = self.model_path.to_string_lossy().into_owned();
        let ctx = WhisperContext::new_with_params(&path_str, WhisperContextParameters::default())
            .map_err(|e| SttError::WhisperLoadFailed(format!("{e}")))?;
        let arc = Arc::new(Mutex::new(ctx));
        let _ = self.ctx.set(arc.clone());
        Ok(arc)
    }

    /// Run whisper on PCM f32 16 kHz mono. Returns the transcript and the
    /// detected language (if any).
    pub async fn transcribe(&self, samples: Vec<f32>) -> Result<(String, Option<String>), SttError> {
        let ctx = self.ensure_ctx()?;
        task::spawn_blocking(move || {
            let ctx = ctx.lock().expect("whisper mutex poisoned");
            let mut state = ctx.create_state()
                .map_err(|e| SttError::WhisperInferenceFailed(format!("{e}")))?;
            let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
            params.set_print_progress(false);
            params.set_print_realtime(false);
            params.set_print_timestamps(false);
            params.set_print_special(false);
            // language=None → autodetect
            state.full(params, &samples)
                .map_err(|e| SttError::WhisperInferenceFailed(format!("{e}")))?;
            let n = state.full_n_segments()
                .map_err(|e| SttError::WhisperInferenceFailed(format!("{e}")))?;
            let mut text = String::new();
            for i in 0..n {
                let s = state.full_get_segment_text(i)
                    .map_err(|e| SttError::WhisperInferenceFailed(format!("{e}")))?;
                text.push_str(&s);
            }
            let lang = state.full_lang_id().ok().map(|id| id.to_string());
            Ok((text.trim().to_string(), lang))
        })
        .await
        .map_err(|e| SttError::WhisperInferenceFailed(format!("join: {e}")))?
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::stt::decode::decode_to_pcm_f32;
    use rightclaw::agent::types::WhisperModel;
    use rightclaw::stt::{download_model, model_cache_path};
    use std::path::PathBuf;

    /// Returns a path to a cached ggml-tiny.bin under the user's
    /// RIGHTCLAW_HOME or `~/.rightclaw`. Downloads if missing.
    async fn ensure_tiny_model() -> PathBuf {
        let home = std::env::var_os("RIGHTCLAW_HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|| dirs::home_dir().unwrap().join(".rightclaw"));
        let p = model_cache_path(&home, WhisperModel::Tiny);
        if !p.exists() {
            download_model(WhisperModel::Tiny, &p).await
                .expect("test setup: download ggml-tiny");
        }
        p
    }

    fn fixture(name: &str) -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
    }

    #[tokio::test]
    async fn missing_model_returns_modelmissing() {
        let engine = WhisperEngine::new(PathBuf::from("/nonexistent/ggml-tiny.bin"));
        let res = engine.transcribe(vec![0.0; 16000]).await;
        match res {
            Err(SttError::ModelMissing(_)) => {},
            other => panic!("expected ModelMissing, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn inference_returns_known_words() {
        let model = ensure_tiny_model().await;
        let samples = decode_to_pcm_f32(&fixture("hello.oga")).await
            .expect("ffmpeg required");
        let engine = WhisperEngine::new(model);
        let (text, _lang) = engine.transcribe(samples).await
            .expect("inference should succeed");
        let lower = text.to_lowercase();
        assert!(
            lower.contains("test") || lower.contains("this"),
            "expected 'this is a test' content, got: {text:?}"
        );
    }
}
```

If `dirs` is not already a dev-dep of the bot crate:
```bash
cargo add --package rightclaw-bot --dev dirs
```

- [ ] **Step 2: Run tests**

```bash
cargo test -p rightclaw-bot stt::whisper
```
Expected: 2 tests pass. First run downloads `ggml-tiny.bin` to `~/.rightclaw/cache/whisper/` (~75 MB).

- [ ] **Step 3: Commit**

```bash
git add crates/bot/src/stt/whisper.rs crates/bot/Cargo.toml Cargo.lock
git commit -m "feat(stt): WhisperEngine with lazy context and serialized inference"
```

---

## Task 10: `bot::stt::Transcriber` and `MAX_AUDIO_FILE_MB` enforcement

**Files:**
- Modify: `crates/bot/src/stt/mod.rs`

- [ ] **Step 1: Write failing tests**

Append to `crates/bot/src/stt/mod.rs`:

```rust
pub struct TranscriptionResult {
    pub text: String,
    pub detected_language: Option<String>,
}

pub struct Transcriber {
    engine: whisper::WhisperEngine,
}

impl Transcriber {
    pub fn new(model_path: std::path::PathBuf) -> Self {
        Self { engine: whisper::WhisperEngine::new(model_path) }
    }

    pub async fn transcribe_voice(&self, file: &std::path::Path) -> Result<TranscriptionResult, SttError> {
        self.transcribe_inner(file).await
    }

    pub async fn transcribe_video_note(&self, file: &std::path::Path) -> Result<TranscriptionResult, SttError> {
        self.transcribe_inner(file).await
    }

    async fn transcribe_inner(&self, file: &std::path::Path) -> Result<TranscriptionResult, SttError> {
        let meta = tokio::fs::metadata(file).await
            .map_err(|e| SttError::FfmpegFailed(format!("stat {}: {e}", file.display())))?;
        let size_mb = meta.len() / (1024 * 1024);
        if size_mb > MAX_AUDIO_FILE_MB {
            return Err(SttError::FileTooLarge { size_mb, max_mb: MAX_AUDIO_FILE_MB });
        }
        let samples = decode::decode_to_pcm_f32(file).await?;
        let (text, lang) = self.engine.transcribe(samples).await?;
        Ok(TranscriptionResult { text, detected_language: lang })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rightclaw::agent::types::WhisperModel;
    use rightclaw::stt::model_cache_path;
    use std::path::PathBuf;

    fn fixture(name: &str) -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
    }

    async fn tiny_path() -> PathBuf {
        let home = std::env::var_os("RIGHTCLAW_HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|| dirs::home_dir().unwrap().join(".rightclaw"));
        let p = model_cache_path(&home, WhisperModel::Tiny);
        if !p.exists() {
            rightclaw::stt::download_model(WhisperModel::Tiny, &p).await.unwrap();
        }
        p
    }

    #[tokio::test]
    async fn transcribe_voice_end_to_end() {
        let t = Transcriber::new(tiny_path().await);
        let res = t.transcribe_voice(&fixture("hello.oga")).await.unwrap();
        assert!(res.text.to_lowercase().contains("test"));
    }

    #[tokio::test]
    async fn transcribe_video_note_end_to_end() {
        let t = Transcriber::new(tiny_path().await);
        let res = t.transcribe_video_note(&fixture("circle.mp4")).await.unwrap();
        assert!(res.text.to_lowercase().contains("test"));
    }

    #[tokio::test]
    async fn file_too_large_returns_error() {
        // Create a 30MB sparse file with a valid-looking head
        let tmp = tempfile::NamedTempFile::new().unwrap();
        let f = tokio::fs::File::create(tmp.path()).await.unwrap();
        f.set_len(30 * 1024 * 1024).await.unwrap();
        drop(f);
        let t = Transcriber::new(PathBuf::from("/nonexistent.bin"));
        match t.transcribe_voice(tmp.path()).await {
            Err(SttError::FileTooLarge { size_mb: 30, max_mb: 25 }) => {},
            other => panic!("expected FileTooLarge, got {other:?}"),
        }
    }
}
```

- [ ] **Step 2: Run tests**

```bash
cargo test -p rightclaw-bot stt::tests
```
Expected: 3 tests pass.

- [ ] **Step 3: Commit**

```bash
git add crates/bot/src/stt/mod.rs
git commit -m "feat(stt): Transcriber API with file-size guard"
```

---

## Task 11: `bot::stt::markers` — Russian marker constants

**Files:**
- Create: `crates/bot/src/stt/markers.rs`
- Modify: `crates/bot/src/stt/mod.rs` (add `pub mod markers;`)

- [ ] **Step 1: Write failing tests**

`crates/bot/src/stt/markers.rs`:

```rust
//! Russian-language markers injected into the user-payload to inform the
//! agent that the original message was voice/video-note.

use crate::stt::SttError;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoiceKind { Voice, VideoNote }

pub fn marker_success(kind: VoiceKind, transcript: &str) -> String {
    match kind {
        VoiceKind::Voice => format!(
            "[Пользователь надиктовал голосовое сообщение. Расшифровка: {transcript:?}]"
        ),
        VoiceKind::VideoNote => format!(
            "[Пользователь записал кружок (видео-сообщение). Расшифровка: {transcript:?}]"
        ),
    }
}

pub fn marker_for_error(kind: VoiceKind, err: &SttError) -> String {
    let (subject, gendered_too_large) = match kind {
        VoiceKind::Voice =>
            ("Пользователь прислал голосовое сообщение", "оно слишком большое"),
        VoiceKind::VideoNote =>
            ("Пользователь прислал кружок (видео-сообщение)", "он слишком большой"),
    };
    match err {
        SttError::FfmpegNotFound => format!(
            "[{subject}, но расшифровка недоступна — на хосте не установлен ffmpeg.]"
        ),
        SttError::ModelMissing(_) => format!(
            "[{subject}, но модель распознавания речи не загружена. Запусти 'rightclaw up' заново.]"
        ),
        SttError::FileTooLarge { size_mb, .. } => format!(
            "[{subject}, но {gendered_too_large} для расшифровки ({size_mb} MB).]"
        ),
        SttError::FfmpegFailed(_)
        | SttError::WhisperLoadFailed(_)
        | SttError::WhisperInferenceFailed(_) => format!(
            "[{subject}, но расшифровать не удалось: {err}]"
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn success_voice_includes_transcript_and_kind() {
        let s = marker_success(VoiceKind::Voice, "привет");
        assert!(s.contains("надиктовал голосовое сообщение"));
        assert!(s.contains("\"привет\""));
    }

    #[test]
    fn success_video_note_uses_kruzhok_wording() {
        let s = marker_success(VoiceKind::VideoNote, "hi");
        assert!(s.contains("записал кружок"));
        assert!(s.contains("(видео-сообщение)"));
    }

    #[test]
    fn ffmpeg_missing_voice_marker() {
        let m = marker_for_error(VoiceKind::Voice, &SttError::FfmpegNotFound);
        assert!(m.contains("голосовое сообщение"));
        assert!(m.contains("не установлен ffmpeg"));
    }

    #[test]
    fn ffmpeg_missing_video_note_marker() {
        let m = marker_for_error(VoiceKind::VideoNote, &SttError::FfmpegNotFound);
        assert!(m.contains("кружок"));
        assert!(m.contains("не установлен ffmpeg"));
    }

    #[test]
    fn model_missing_marker() {
        let m = marker_for_error(
            VoiceKind::Voice,
            &SttError::ModelMissing(PathBuf::from("/x")),
        );
        assert!(m.contains("rightclaw up"));
    }

    #[test]
    fn file_too_large_uses_gendered_form() {
        let voice = marker_for_error(
            VoiceKind::Voice,
            &SttError::FileTooLarge { size_mb: 30, max_mb: 25 },
        );
        assert!(voice.contains("оно слишком большое"));
        assert!(voice.contains("30 MB"));

        let circle = marker_for_error(
            VoiceKind::VideoNote,
            &SttError::FileTooLarge { size_mb: 30, max_mb: 25 },
        );
        assert!(circle.contains("он слишком большой"));
    }

    #[test]
    fn generic_failure_includes_short_reason() {
        let m = marker_for_error(
            VoiceKind::Voice,
            &SttError::WhisperInferenceFailed("oom".into()),
        );
        assert!(m.contains("расшифровать не удалось"));
        assert!(m.contains("oom"));
    }
}
```

In `crates/bot/src/stt/mod.rs`, add at top:

```rust
pub mod markers;
```

- [ ] **Step 2: Run tests**

```bash
cargo test -p rightclaw-bot stt::markers
```
Expected: 7 tests pass.

- [ ] **Step 3: Commit**

```bash
git add crates/bot/src/stt/markers.rs crates/bot/src/stt/mod.rs
git commit -m "feat(stt): Russian markers for voice and video-note outcomes"
```

---

## Task 12: `SttContext` and `combine_markers_with_text` helpers

**Files:**
- Modify: `crates/bot/src/stt/mod.rs`

- [ ] **Step 1: Write failing tests**

Append to `crates/bot/src/stt/mod.rs`:

```rust
use std::sync::Arc;

/// Bundle passed through the worker pipeline so transcription is opt-in
/// per-bot and ffmpeg presence is checked once at startup.
pub struct SttContext {
    pub transcriber: Arc<Transcriber>,
    pub ffmpeg_available: bool,
}

/// Build the final text for a message: prepend each marker on its own line,
/// blank line, then the user text (if any).
pub fn combine_markers_with_text(markers: &[String], user_text: Option<&str>) -> Option<String> {
    if markers.is_empty() {
        return user_text.map(str::to_owned);
    }
    let mut out = markers.join("\n");
    if let Some(t) = user_text {
        let t = t.trim();
        if !t.is_empty() {
            out.push_str("\n\n");
            out.push_str(t);
        }
    }
    Some(out)
}

#[cfg(test)]
mod combine_tests {
    use super::*;

    #[test]
    fn no_markers_returns_user_text() {
        assert_eq!(combine_markers_with_text(&[], Some("hi")), Some("hi".into()));
        assert_eq!(combine_markers_with_text(&[], None), None);
    }

    #[test]
    fn marker_only_when_no_text() {
        let m = vec!["[m]".to_string()];
        assert_eq!(combine_markers_with_text(&m, None), Some("[m]".into()));
        assert_eq!(combine_markers_with_text(&m, Some("")), Some("[m]".into()));
    }

    #[test]
    fn marker_prepended_with_blank_line_above_text() {
        let m = vec!["[m]".to_string()];
        assert_eq!(
            combine_markers_with_text(&m, Some("hello")),
            Some("[m]\n\nhello".into()),
        );
    }

    #[test]
    fn multiple_markers_each_on_own_line() {
        let m = vec!["[a]".to_string(), "[b]".to_string()];
        assert_eq!(
            combine_markers_with_text(&m, Some("x")),
            Some("[a]\n[b]\n\nx".into()),
        );
    }
}
```

- [ ] **Step 2: Run tests**

```bash
cargo test -p rightclaw-bot stt::combine_tests
```
Expected: 4 tests pass.

- [ ] **Step 3: Commit**

```bash
git add crates/bot/src/stt/mod.rs
git commit -m "feat(stt): SttContext and combine_markers_with_text helper"
```

---

## Task 13: `transcribe_or_marker` — single-attachment helper

**Files:**
- Modify: `crates/bot/src/stt/mod.rs`

This is the unit that the modified `download_attachments` calls per voice/video-note.

- [ ] **Step 1: Write failing tests**

Append to `crates/bot/src/stt/mod.rs`:

```rust
/// Try to transcribe `host_path`. On success, returns the success marker.
/// On any error, returns the corresponding error marker. Always returns
/// some marker — the caller injects it into the payload.
pub async fn transcribe_or_marker(
    ctx: &SttContext,
    kind: markers::VoiceKind,
    host_path: &std::path::Path,
) -> String {
    if !ctx.ffmpeg_available {
        return markers::marker_for_error(kind, &SttError::FfmpegNotFound);
    }
    let result = match kind {
        markers::VoiceKind::Voice => ctx.transcriber.transcribe_voice(host_path).await,
        markers::VoiceKind::VideoNote => ctx.transcriber.transcribe_video_note(host_path).await,
    };
    match result {
        Ok(r) => markers::marker_success(kind, &r.text),
        Err(e) => {
            tracing::warn!("STT failed for {}: {e}", host_path.display());
            markers::marker_for_error(kind, &e)
        }
    }
}

#[cfg(test)]
mod transcribe_or_marker_tests {
    use super::*;
    use rightclaw::agent::types::WhisperModel;
    use rightclaw::stt::model_cache_path;
    use std::path::PathBuf;

    fn fixture(name: &str) -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
    }

    async fn tiny_ctx(ffmpeg_available: bool) -> SttContext {
        let home = std::env::var_os("RIGHTCLAW_HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|| dirs::home_dir().unwrap().join(".rightclaw"));
        let p = model_cache_path(&home, WhisperModel::Tiny);
        if !p.exists() {
            rightclaw::stt::download_model(WhisperModel::Tiny, &p).await.unwrap();
        }
        SttContext {
            transcriber: Arc::new(Transcriber::new(p)),
            ffmpeg_available,
        }
    }

    #[tokio::test]
    async fn voice_success_returns_success_marker() {
        let ctx = tiny_ctx(true).await;
        let m = transcribe_or_marker(&ctx, markers::VoiceKind::Voice, &fixture("hello.oga")).await;
        assert!(m.contains("надиктовал"));
        assert!(m.to_lowercase().contains("test"));
    }

    #[tokio::test]
    async fn ffmpeg_unavailable_returns_error_marker_without_running_ffmpeg() {
        let ctx = tiny_ctx(false).await;
        let m = transcribe_or_marker(&ctx, markers::VoiceKind::Voice, &fixture("hello.oga")).await;
        assert!(m.contains("не установлен ffmpeg"));
    }

    #[tokio::test]
    async fn missing_model_returns_model_missing_marker() {
        let ctx = SttContext {
            transcriber: Arc::new(Transcriber::new(PathBuf::from("/nonexistent/x.bin"))),
            ffmpeg_available: true,
        };
        let m = transcribe_or_marker(&ctx, markers::VoiceKind::Voice, &fixture("hello.oga")).await;
        assert!(m.contains("rightclaw up"));
    }
}
```

- [ ] **Step 2: Run tests**

```bash
cargo test -p rightclaw-bot stt::transcribe_or_marker
```
Expected: 3 tests pass.

- [ ] **Step 3: Commit**

```bash
git add crates/bot/src/stt/mod.rs
git commit -m "feat(stt): transcribe_or_marker single-attachment helper"
```

---

## Task 14: Refactor `download_attachments` to integrate STT

**Files:**
- Modify: `crates/bot/src/telegram/attachments.rs`

The existing `download_attachments`:
- downloads each attachment from Telegram to `tmp/inbox/`,
- if sandboxed: uploads to sandbox, deletes host temp,
- else: moves to host `inbox/`,
- returns `Vec<ResolvedAttachment>`.

We change the signature to also accept `Option<&SttContext>` and return a tuple `(Vec<ResolvedAttachment>, Vec<String>)` where the second element is the list of voice markers.

Behavior change: when `stt` is `Some(_)` AND attachment kind is `Voice` or `VideoNote`, the file is downloaded to host tmp, transcribed (or error-marker'd), and **not** uploaded to sandbox / not moved to host inbox. The host tmp file is deleted regardless of outcome.

- [ ] **Step 1: Update signature and integrate**

In `crates/bot/src/telegram/attachments.rs`, modify `download_attachments`:

```rust
pub async fn download_attachments(
    attachments: &[InboundAttachment],
    message_id: i32,
    bot: &super::BotType,
    agent_dir: &std::path::Path,
    ssh_config_path: Option<&std::path::Path>,
    resolved_sandbox: Option<&str>,
    chat_id: teloxide::types::ChatId,
    eff_thread_id: i64,
    stt: Option<&crate::stt::SttContext>,
) -> Result<(Vec<ResolvedAttachment>, Vec<String>),
            Box<dyn std::error::Error + Send + Sync>>
{
    use teloxide::net::Download;
    use teloxide::requests::Requester;
    use tokio::io::AsyncWriteExt;

    let tmp_dir = agent_dir.join("tmp/inbox");
    tokio::fs::create_dir_all(&tmp_dir).await?;

    let sandboxed = ssh_config_path.is_some();
    if !sandboxed {
        tokio::fs::create_dir_all(agent_dir.join("inbox")).await?;
    }

    let mut resolved = Vec::with_capacity(attachments.len());
    let mut markers = Vec::new();

    for (idx, att) in attachments.iter().enumerate() {
        // existing size limit check (unchanged) ...

        let mime = att.mime_type.as_deref().unwrap_or("application/octet-stream");
        let ext = mime_to_extension(mime);
        let file_name = format!("{}_{message_id}_{idx}.{ext}", att.kind.as_str());

        // Download from Telegram (unchanged)
        let file = bot.get_file(teloxide::types::FileId(att.file_id.clone())).await?;
        let host_path = tmp_dir.join(&file_name);
        let mut dst = tokio::fs::File::create(&host_path).await?;
        bot.download_file(&file.path, &mut dst).await?;
        dst.flush().await?;
        drop(dst);

        // NEW: STT short-circuit for voice / video_note
        let stt_kind = match (att.kind, stt) {
            (AttachmentKind::Voice, Some(_)) => Some(crate::stt::markers::VoiceKind::Voice),
            (AttachmentKind::VideoNote, Some(_)) => Some(crate::stt::markers::VoiceKind::VideoNote),
            _ => None,
        };
        if let (Some(kind), Some(ctx)) = (stt_kind, stt) {
            let marker = crate::stt::transcribe_or_marker(ctx, kind, &host_path).await;
            markers.push(marker);
            // Drop the file on host — it does not go to sandbox or to inbox.
            if let Err(e) = tokio::fs::remove_file(&host_path).await {
                tracing::warn!("STT cleanup failed for {}: {e}", host_path.display());
            }
            continue;
        }

        // Existing upload/move logic (unchanged) ...
        // (sandbox upload + remove host file, OR move host_path → inbox)
        // and push ResolvedAttachment onto `resolved`.
    }

    Ok((resolved, markers))
}
```

(Above is a structural diff sketch; preserve all existing branches inside the
loop verbatim — this task only adds the STT short-circuit and changes the
return type.)

- [ ] **Step 2: Update all callers**

Find every caller of `download_attachments`:

```bash
rg -n 'download_attachments\(' crates/
```

For each call site, propagate the new signature: pass `None` as the new `stt:` argument for now and destructure the new tuple. Worker call site (line ~404) becomes:

```rust
let (resolved, voice_markers) = match super::attachments::download_attachments(
    &msg.attachments,
    msg.message_id,
    &ctx.bot,
    &ctx.agent_dir,
    ctx.ssh_config_path.as_deref(),
    ctx.resolved_sandbox.as_deref(),
    tg_chat_id,
    eff_thread_id,
    ctx.stt.as_deref(),  // wired in Task 15
).await {
    Ok(r) => r,
    Err(e) => { /* existing error-handling */ }
};
```

For the worker, we also need to compute `text` = `combine_markers_with_text(&voice_markers, msg.text.as_deref())`. Replace the `text: msg.text.clone()` line in the `InputMessage` push:

```rust
input_messages.push(super::attachments::InputMessage {
    message_id: msg.message_id,
    text: crate::stt::combine_markers_with_text(&voice_markers, msg.text.as_deref()),
    // ... unchanged fields ...
});
```

(`InputMessage.text` is already `Option<String>` per existing code — verify.)

- [ ] **Step 3: Compile**

```bash
cargo build -p rightclaw-bot
```
Expected: clean build. Fix any caller signature mismatches.

- [ ] **Step 4: Add an integration test**

New test in `crates/bot/src/telegram/attachments.rs` mod tests (or new file). Since teloxide download is hard to unit-test, focus the test on the STT branch by calling a smaller refactored helper if needed; otherwise rely on Task 13's coverage and document this is integration-tested via end-to-end.

If the existing module already has a `tests` mod, add:

```rust
// Smoke test: signature compiles and Vec markers default to empty.
#[test]
fn empty_attachments_yields_empty_resolved_and_markers() {
    // No async runtime required for this guarantee — lifted via const-style
    // assertion: simply re-exercise the type signature here.
    let _: fn(/* args */) = |_a, _b, _c, _d, _e, _f, _g, _h, _stt|
        -> _ { unimplemented!() };
}
```

(The genuine end-to-end coverage lives in Tasks 13 and 16.)

- [ ] **Step 5: Commit**

```bash
git add crates/bot/src/telegram/attachments.rs crates/bot/src/telegram/worker.rs
git commit -m "refactor(stt): download_attachments returns (resolved, markers); voice short-circuits"
```

---

## Task 15: Wire `SttContext` into bot startup and `WorkerContext`

**Files:**
- Modify: `crates/bot/src/lib.rs`
- Modify: `crates/bot/src/telegram/worker.rs`

- [ ] **Step 1: Add `stt` field to `WorkerContext`**

In `crates/bot/src/telegram/worker.rs`, locate `WorkerContext` (around the field set including `resolved_sandbox`, `ssh_config_path`, etc.) and add:

```rust
pub stt: Option<std::sync::Arc<crate::stt::SttContext>>,
```

Update every place that constructs `WorkerContext` to initialize the new field. Most constructions clone an existing context — `ctx.stt.clone()` is the right idiom. For new construction in `lib.rs`, see Step 2.

- [ ] **Step 2: Build `SttContext` at bot startup**

In `crates/bot/src/lib.rs`, find the place where `AgentConfig` is loaded and the bot is being constructed. Add (post-config-load, pre-worker-spawn):

```rust
use std::sync::Arc;

let stt = if config.stt.enabled {
    let model_path = rightclaw::stt::model_cache_path(&home, config.stt.model);
    let transcriber = Arc::new(crate::stt::Transcriber::new(model_path));
    let ffmpeg_available = rightclaw::stt::ffmpeg_available();
    if !ffmpeg_available {
        tracing::warn!(
            "ffmpeg not found in PATH — voice messages will be answered with an error marker. \
             Install: brew install ffmpeg / apt install ffmpeg."
        );
    }
    Some(Arc::new(crate::stt::SttContext { transcriber, ffmpeg_available }))
} else {
    None
};
```

Then pass `stt: stt.clone()` into the initial `WorkerContext`.

- [ ] **Step 3: Compile**

```bash
cargo build -p rightclaw-bot
```
Expected: clean build.

- [ ] **Step 4: Smoke test (manual, optional)**

```bash
cargo test -p rightclaw-bot
```
Expected: all existing tests pass; new STT tests still pass.

- [ ] **Step 5: Commit**

```bash
git add crates/bot/src/lib.rs crates/bot/src/telegram/worker.rs
git commit -m "feat(stt): build SttContext at bot startup and plumb into WorkerContext"
```

---

## Task 16: Wire `ensure_models_cached` into `rightclaw up`

**Files:**
- Modify: `crates/rightclaw-cli/src/main.rs` (or wherever `cmd_up` is defined; locate via `rg "fn cmd_up\|fn cmd_up_async\|up_command" crates/`)

- [ ] **Step 1: Locate the `up` command body**

```bash
rg -n 'fn cmd_up|"up"\s*=>' crates/rightclaw-cli/src/
```

- [ ] **Step 2: Insert model-cache step after agent discovery**

After the agents are discovered and before `process-compose.yaml` is generated, add:

```rust
{
    use std::collections::HashSet;
    use rightclaw::agent::types::WhisperModel;

    let mut models: HashSet<WhisperModel> = HashSet::new();
    for agent in &discovered_agents {
        if agent.config.stt.enabled {
            models.insert(agent.config.stt.model);
        }
    }
    if !models.is_empty() {
        println!("Ensuring whisper models are cached...");
        if let Err(e) = rightclaw::stt::ensure_models_cached(&home, &models).await {
            eprintln!("WARN: model cache step failed: {e}");
        }
    }
}
```

(Use the actual variable name for the discovered-agents collection from the surrounding code.)

- [ ] **Step 3: Manual verification**

```bash
# Without ffmpeg in PATH (use a clean shell or `PATH=/usr/bin cargo run ...`):
PATH=/usr/bin cargo run -p rightclaw-cli -- up --help  # baseline; up may need a real env
```

The clean check is integration-tested via Task 5's `ensure_models_cached_skips_when_ffmpeg_missing`.

- [ ] **Step 4: Commit**

```bash
git add crates/rightclaw-cli/src/main.rs
git commit -m "feat(stt): rightclaw up downloads whisper models when ffmpeg present"
```

---

## Task 17: Doctor checks

**Files:**
- Modify: `crates/rightclaw/src/doctor.rs`

- [ ] **Step 1: Locate the doctor check registry**

```bash
rg -n 'fn check_|fn run_doctor|DoctorCheck' crates/rightclaw/src/doctor.rs
```

- [ ] **Step 2: Add ffmpeg + model-presence checks**

Add a new check function (style-match the existing ones — they likely return `Result<(), DoctorWarning>` or push into a `Vec<DoctorReport>`; mirror the pattern).

```rust
fn check_stt(home: &std::path::Path, agents: &[AgentDef]) -> Vec<DoctorReport> {
    let mut out = Vec::new();
    let need_ffmpeg = agents.iter().any(|a| a.config.stt.enabled);

    if need_ffmpeg && !crate::stt::ffmpeg_available() {
        out.push(DoctorReport::warn(
            "ffmpeg",
            "ffmpeg not found in PATH — voice transcription disabled.",
            Some("brew install ffmpeg  # macOS\napt install ffmpeg  # Linux"),
        ));
    }

    for agent in agents {
        if !agent.config.stt.enabled { continue; }
        let p = crate::stt::model_cache_path(home, agent.config.stt.model);
        if !p.exists() {
            out.push(DoctorReport::warn(
                "stt-model",
                &format!(
                    "{}: whisper model {} not cached",
                    agent.name, agent.config.stt.model.filename()
                ),
                Some("run: rightclaw up"),
            ));
        }
    }
    out
}
```

(Adjust the `DoctorReport` constructor to match existing helpers in the file.)

- [ ] **Step 3: Wire `check_stt` into the main doctor entry**

Find `pub fn run_doctor(...)` (or `async fn ...`). Append:

```rust
reports.extend(check_stt(home, &agents));
```

- [ ] **Step 4: Add tests**

Append tests to `crates/rightclaw/src/doctor.rs`:

```rust
#[cfg(test)]
mod stt_doctor_tests {
    use super::*;
    use crate::agent::types::{AgentConfig, AgentDef, SttConfig, WhisperModel};
    use std::path::PathBuf;

    fn agent_with_stt(name: &str, enabled: bool, model: WhisperModel) -> AgentDef {
        AgentDef {
            name: name.into(),
            path: PathBuf::from("/tmp"),
            config: AgentConfig {
                stt: SttConfig { enabled, model },
                ..AgentConfig::default()
            },
            // ... other required fields with sensible defaults
        }
    }

    #[test]
    fn warn_on_missing_model_when_enabled() {
        let tmp = tempfile::TempDir::new().unwrap();
        let agents = vec![agent_with_stt("a", true, WhisperModel::Small)];
        let reports = check_stt(tmp.path(), &agents);
        assert!(reports.iter().any(|r| r.message.contains("ggml-small.bin")));
    }

    #[test]
    fn silent_when_stt_disabled() {
        let tmp = tempfile::TempDir::new().unwrap();
        let agents = vec![agent_with_stt("a", false, WhisperModel::Small)];
        let reports = check_stt(tmp.path(), &agents);
        // No model warning. ffmpeg warning only if it was the sole reason.
        assert!(reports.iter().all(|r| !r.message.contains("ggml")));
    }
}
```

(Adjust `AgentDef` construction to match the actual struct fields — use `..Default::default()` or fill in required fields.)

- [ ] **Step 5: Run tests**

```bash
cargo test -p rightclaw doctor::stt_doctor_tests
```
Expected: 2 tests pass.

- [ ] **Step 6: Commit**

```bash
git add crates/rightclaw/src/doctor.rs
git commit -m "feat(stt): doctor warns on missing ffmpeg and uncached models"
```

---

## Task 18: Update `ARCHITECTURE.md`

**Files:**
- Modify: `ARCHITECTURE.md`

- [ ] **Step 1: Add `stt` to the rightclaw module map**

In the `rightclaw (core)` module map, add an entry:

```
├── stt.rs              # whisper model cache paths, ffmpeg check, download_model, ensure_models_cached
```

- [ ] **Step 2: Add `stt` to the rightclaw-bot module map**

In the `rightclaw-bot` module map, add:

```
├── stt/
│   ├── mod.rs          # Transcriber, SttError, SttContext, transcribe_or_marker, combine_markers_with_text
│   ├── decode.rs       # ffmpeg subprocess: file → Vec<f32> PCM 16k mono
│   ├── whisper.rs      # WhisperEngine: lazy WhisperContext + serialized inference
│   └── markers.rs      # Russian markers (voice/video_note × success/error)
```

- [ ] **Step 3: Add a "Voice transcription" subsection under Data Flow**

Add a new subsection:

```markdown
### Voice transcription

`voice` and `video_note` Telegram attachments are transcribed on the host
inside `download_attachments` when `agent.yaml`'s `stt.enabled` is true and
ffmpeg is present. The transcript is wrapped in a Russian marker
(`[Пользователь надиктовал...]` / `[Пользователь записал кружок...]`) and
prepended to the user-message text. The original audio file is dropped on
the host — it never reaches the sandbox.

Models live at `~/.rightclaw/cache/whisper/ggml-<model>.bin` and are
downloaded at `rightclaw up` (skipped if ffmpeg is missing). Default model
is `small`; per-agent override via `agent.yaml`:

    stt:
      enabled: true
      model: small   # tiny | base | small | medium | large-v3

When ffmpeg is missing or the model file is absent, the bot still runs;
voice messages produce an error marker that the agent relays to the user.
```

- [ ] **Step 4: Add `ffmpeg` to External Dependencies note**

In the External Dependencies (Not Rust Crates) table, add a row:

```
| ffmpeg | system | Decode voice/video_note to PCM for whisper-rs | Optional — bot runs without it; voice transcription disabled. doctor warns. |
```

- [ ] **Step 5: Commit**

```bash
git add ARCHITECTURE.md
git commit -m "docs(arch): document voice transcription pipeline"
```

---

## Final verification

- [ ] **Run the full test suite**

```bash
cargo test --workspace
```
Expected: all green. First-run bot tests download `ggml-tiny.bin`.

- [ ] **Run clippy**

```bash
cargo clippy --workspace -- -D warnings
```
Expected: clean.

- [ ] **Manual end-to-end (optional)**

1. Ensure ffmpeg is installed on the dev machine.
2. `cargo run -p rightclaw-cli -- up` — confirm "Ensuring whisper models are cached..." line appears and `~/.rightclaw/cache/whisper/ggml-small.bin` is created.
3. Send a voice message to the right agent's bot; verify the agent replies with a transcript-aware response.
4. Send a video note; verify same.

---

## Self-review summary

- **Spec coverage:** every Decision-table row maps to a task. STT location → Tasks 8–13 (bot crate). Engine → Tasks 6, 9. Default model + override → Task 1. Audio decoding → Task 8. Missing-ffmpeg fail-soft → Tasks 11, 13, 15, 17. Model storage + download timing → Tasks 4, 5, 16. Attachment scope → Task 14. Payload format → Tasks 11, 12, 14. Config schema → Tasks 1, 2.
- **Markers:** every variant in spec table covered by tests in Task 11.
- **Error handling:** all `SttError` variants surfaced via `marker_for_error`; tested in Task 11.
- **Testing approach:** no `#[ignore]`; ffmpeg-required tests fail with a clear hint; ggml-tiny is downloaded once and shared across runs.
- **Doctor:** Task 17 covers both checks (ffmpeg + per-agent model presence).
- **Docs:** Task 18 updates ARCHITECTURE.md.
- **No placeholders:** every code block is concrete; the only "look up X" instructions are explicit lookup commands (cargo search, rg) for the engineer to resolve at execution time.
