YazSes v0.3.0 — Build Prompt for Claude Code¶
Repository and Architecture¶
You are implementing YazSes v0.3.0. The repository is at /home/mohsen/scratch/yazses. The project is a cross-platform, offline hold-to-talk voice dictation daemon (Linux/macOS/Windows). It uses Python 3.11+, managed with uv. The test command is uv run pytest tests/ -v.
Existing pipeline (do not break):
The daemon orchestrator is src/yazses/core/daemon.py. Platform abstractions (hotkey, injector, IPC server/client, lifecycle, tray) are in src/yazses/platform/. All platform backends implement Protocol interfaces declared in src/yazses/platform/base.py. Config is a dataclass tree in src/yazses/config.py, loaded from TOML at ~/.config/yazses/config.toml. IPC uses JSON-RPC 2.0 over newline-delimited JSON on a Unix socket (Linux/macOS) or named pipe (Windows), implemented in src/yazses/ipc/.
Files to Create¶
Create all of the following new files. Do not modify existing files unless explicitly instructed.
cap-001 — SSH/Remote Voice Forwarding¶
| File | Responsibility |
|---|---|
src/yazses/remote/__init__.py | Empty package init |
src/yazses/remote/forwarder.py | SSH tunnel lifecycle: spawns ssh -R 9875:localhost:9875 user@host yazses-agent --listen 9875 via subprocess.Popen. Exposes connect(host, port, key_file), disconnect(), is_connected() -> bool. Monitors the subprocess and sets a _connected: bool flag. |
src/yazses/remote/local_proxy.py | Listens on local TCP loopback port 9875 for JSON-RPC inject(text) calls arriving from the SSH tunnel. Bridges received text to the InjectorBackend passed at construction. |
src/yazses/remote/agent.py | Standalone script: yazses-agent --listen PORT. Accepts JSON-RPC inject(text) over TCP loopback. Calls remote/inject.py to type the text. Zero faster-whisper imports. |
src/yazses/remote/inject.py | Mirrors inject/auto.py: probes for xdotool, ydotool, wtype (Linux), SendInput (Windows), CGEventPost (macOS). Returns best available InjectorBackend instance. |
Implementation notes for cap-001: - The SSH command is: ssh -o ExitOnForwardFailure=yes -R 9875:127.0.0.1:9875 [-i key_file] [-p port] host yazses-agent --listen 9875 - forwarder.py must catch FileNotFoundError (ssh not installed) and raise a descriptive YazSesError. - local_proxy.py uses asyncio streams to accept connections on 127.0.0.1:9875. - agent.py is the entry point for the yazses-agent CLI command (add to pyproject.toml [project.scripts] as yazses-agent = "yazses.remote.agent:main"). - Session state: add REMOTE_SETUP and REMOTE_ACTIVE to TrayState enum in platform/base.py. - Add IPC methods remote_start, remote_stop, remote_status to ipc/server.py. - Add CLI subcommand yazses remote --host to cli.py that calls remote_start over IPC.
cap-002 — Streaming Transcription with Real-Time Display and Correction¶
| File | Responsibility |
|---|---|
src/yazses/stt/streaming.py | Wraps WhisperModel.transcribe() on a rolling np.ndarray audio buffer. Implements LocalAgreement 2-iteration policy: maintains _prev_hypothesis: str; on each decode tick computes os.path.commonprefix([prev, new]) (adapted for strings); emits only the new stable prefix delta. Exposes stream_partials(audio_queue: asyncio.Queue) -> AsyncGenerator[PartialHypothesis, None]. |
src/yazses/inject/streaming.py | Stateful wrapper around InjectorBackend. Tracks _chars_injected: int. inject_partial(text) injects new chars and increments counter. commit(final_text) issues Shift+Left × _chars_injected then injects final_text. cancel() issues _chars_injected backspaces. |
Implementation notes for cap-002: - PartialHypothesis is a dataclass with fields text: str, is_stable: bool, char_count: int. - The stable prefix comparison is: common = "" then iterate chars of both strings in parallel until mismatch. The stable delta is common[len(last_emitted):]. - StreamingEngine runs in a threading.Thread started by daemon._build_pipeline() when config.streaming.enabled = True. - The audio queue is an asyncio.Queue[np.ndarray]; audio chunks are pushed by AudioRecorder._callback when streaming is active. - Add inject_key_sequence(keys: list[str]) to InjectorBackend Protocol in platform/base.py. Implement in each platform injector: Linux xdotool (xdotool key shift+Left), macOS CGEventPost with shift modifier, Windows SendInput with VK_LEFT + SHIFT. - Add config dataclass StreamingConfig to config.py: enabled: bool = True, partial_interval_ms: int = 300. - Add streaming_enable IPC method to ipc/server.py.
cap-003 — Code Command Grammar¶
| File | Responsibility |
|---|---|
src/yazses/commands/__init__.py | Empty package init |
src/yazses/commands/grammar.py | Stateless classifier. classify(text: str, profile: str = "default") -> CommandIntent. Evaluates an ordered list of compiled re.Pattern entries against text. Returns CommandIntent(intent=DICTATE, ...) if no match. |
src/yazses/commands/dispatch.py | Receives CommandIntent. Routes DICTATE to normal injection. Routes structured intents to InjectorBackend.inject_key_sequence() or subprocess.run. Emits command_dispatched IPC notification. |
src/yazses/commands/profiles.py | Loads per-app profiles from config.toml [commands.profiles.*] sections. Returns ProfileRegistry mapping profile name to GrammarSpec. |
Implementation notes for cap-003: - CommandIntent is a dataclass: intent: IntentType, action: str, args: dict[str, str], raw_text: str. - IntentType is an Enum: DICTATE, NAVIGATE, EDIT, REFACTOR, TERMINAL. - GrammarSpec is a list of (re.Pattern, IntentType, str) tuples (pattern, intent, action_name). - Implement all 12 commands from the PRD FR-003.3 table as compiled regex entries. - Number word normalisation: implement a _normalise_numwords(text: str) -> str helper that replaces "one"→"1", "two"→"2", "three"→"3" (through "ten"→"10") before pattern matching. - dispatch.py action table maps action names to key sequences: "undo" → ["ctrl+z"], "save" → ["ctrl+s"], "go_to_line" → ["ctrl+g", "{n}", "Return"], etc. - Add config dataclass CommandsConfig to config.py: enabled: bool = True, profile: str = "auto", custom: list[dict] = [].
cap-004 — Offline Disfluency Filter¶
| File | Responsibility |
|---|---|
src/yazses/stt/filters/__init__.py | Empty package init |
src/yazses/stt/filters/disfluency.py | Pure-Python filter. filter_transcript(text: str, config: DisfluencyConfig) -> FilterResult. Applies three rules in sequence: (A) filler word regex removal, (B) consecutive 2-gram deduplication, (C) self-correction trigger detection and preceding text removal. |
Implementation notes for cap-004: - FilterResult is a dataclass: text: str, chars_removed: int. - Rule A: compile re.compile(r'\b(' + '|'.join(re.escape(w) for w in config.filler_words) + r')\b[,]?', re.I). Substitute with "". Then normalise multiple spaces. - Rule B: tokenise by whitespace. Walk with a sliding 2-gram window; if tokens[i:i+2] == tokens[i+2:i+4], remove the second occurrence. Repeat until stable. - Rule C: scan for any trigger phrase from config.self_correction_triggers. If found, remove from the last sentence boundary (., !, ?) before the trigger, through to the end of the trigger phrase. - Proper noun and code identifier guard: apply rules only to tokens that are all-lowercase or match \b(um|uh|er|...)\b. Do NOT strip tokens that contain uppercase letters (proper nouns) or contain _, /, . (code identifiers). - Add DisfluencyConfig and FiltersConfig to config.py with all fields from the architecture doc (§5.4). - Wire disfluency.filter_transcript() into core/daemon.py in _process_final_transcript(), after postprocess/cleaner.py and before commands/grammar.py.
cap-005 — Accessibility Profile¶
| File | Responsibility |
|---|---|
src/yazses/accessibility/__init__.py | Empty package init |
src/yazses/accessibility/enroll.py | Interactive CLI wizard. Records 20 utterances via AudioRecorder. Computes per-user RMS stats. Writes vad_threshold, min_silence_ms, pre_speech_padding_ms to config.toml. |
src/yazses/audio/vad_calibrated.py | is_silent_calibrated(audio: np.ndarray, config: AccessibilityConfig) -> bool. Drop-in replacement for vad.is_silent() using config.vad_threshold. |
src/yazses/audio/padding.py | Ring buffer holding last config.accessibility.pre_speech_padding_ms ms of audio. prepend_padding(audio: np.ndarray) -> np.ndarray returns the ring buffer + audio. |
Implementation notes for cap-005: - enroll.py: for each of 20 utterances, record 3 s using AudioRecorder, compute noise_floor = np.abs(audio[:int(0.5 * 16000)]).mean() and speech_rms = np.abs(audio[int(0.5 * 16000):]).mean(). After all 20: vad_threshold = np.percentile(noise_floors, 95) * 3.0, min_silence_ms = max(500, int(np.percentile(pause_durations, 95))). Write to config with tomllib/tomli_w. - platform/linux/hotkey.py: add evdev_device: str = "" to config lookup. If config.hotkey.source == "evdev" and config.hotkey.evdev_device != "", open that device path instead of the default keyboard device. - Add CLI command yazses enroll to cli.py that calls accessibility/enroll.py:run_wizard(). - Extend yazses doctor to accept --accessibility flag: print current values of vad_threshold, min_silence_ms, pre_speech_padding_ms, hotkey.evdev_device, stt.model. - Add AccessibilityConfig and RemoteConfig to config.py (see architecture doc §5.4 for all fields and defaults).
Config Schema Changes¶
Modify src/yazses/config.py to add the following dataclasses (all fields have safe defaults so existing configs remain valid):
@dataclass
class StreamingConfig:
enabled: bool = True
partial_interval_ms: int = 300
partial_marker: str = ""
@dataclass
class DisfluencyConfig:
enabled: bool = True
filler_words: list[str] = field(default_factory=lambda: [
"um", "uh", "er", "ah", "hmm", "like", "you know",
"i mean", "basically", "right", "okay so"
])
self_correction_triggers: list[str] = field(default_factory=lambda: [
"no wait", "delete that", "scratch that", "never mind",
"forget that", "strike that"
])
llm_enabled: bool = False
llm_endpoint: str = "http://localhost:11434"
@dataclass
class AccessibilityConfig:
min_silence_ms: int = 500
pre_speech_padding_ms: int = 200
vad_source: str = "default"
vad_threshold: float = 0.01
@dataclass
class CommandsConfig:
enabled: bool = True
profile: str = "auto"
custom: list[dict] = field(default_factory=list)
@dataclass
class RemoteConfig:
default_host: str = ""
ssh_port: int = 22
agent_port: int = 9875
key_file: str = ""
Add these as fields to the top-level Config dataclass: streaming, filters, accessibility, commands, remote.
Architecture Decisions (Already Made — Do Not Relitigate)¶
All design decisions are locked. Implement exactly as specified:
- ADR-001: Only transcribed text crosses the SSH tunnel. No audio bytes leave the local machine.
remote/forwarder.pyandremote/local_proxy.pyhave zero imports ofsounddeviceoraudio.*. - ADR-002: Use LocalAgreement 2-iteration policy for streaming partial hypotheses. Do not emit raw Whisper output. The stable prefix is the longest common prefix between two consecutive decode passes.
- ADR-003: Command classification is a compiled-regex grammar classifier on text output from Whisper. No second ASR model, no fine-tuning, no LLM classifier.
- ADR-004: Correction-on-commit uses
Shift+Left × _chars_injectedto select partial text, then injects final text to replace. No clipboard operations. No floating overlay window. - ADR-005: Accessibility in v0.3.0 is config parameters + enrollment wizard only. No LoRA fine-tuning. No new ML dependencies.
Tests to Write¶
For each new module, create a corresponding test file in tests/. Write tests that do not require real hardware (mock sounddevice, evdev, xdotool, subprocess):
| Test file | What to test |
|---|---|
tests/test_disfluency_filter.py | Parametrised test over tests/fixtures/disfluency/corpus.json. Assert filter_transcript(input) equals expected. Assert runtime < 10 ms via time.perf_counter. Assert proper nouns and identifiers are unchanged. |
tests/test_grammar_classifier.py | Parametrised test over tests/fixtures/commands/command_phrases.json (50 command phrases) and tests/fixtures/commands/dictation_corpus.txt (25 ~20-word phrases). Assert precision ≥ 90% on command set. Assert zero false positives on dictation corpus. |
tests/test_streaming_engine.py | Load 3 WAV fixtures from tests/fixtures/streaming/. Run StreamingEngine with mock WhisperModel. Assert first inject_partial() call occurs within 600 ms of fixture start. |
tests/test_streaming_injector.py | Unit tests for StreamingInjector state machine. Test inject_partial() increments counter. Test commit() issues correct Shift+Left count. Test cancel() issues correct backspace count. Test counter resets to 0 after each. |
tests/test_accessibility_enroll.py | Load 20 WAV fixtures from tests/fixtures/accessibility/calibration_utterances/. Run run_wizard() with mock AudioRecorder and mock config write. Assert derived vad_threshold is within ±15% of expected value from rms_stats.json. |
tests/test_vad_calibrated.py | Unit tests for is_silent_calibrated(). Assert silent audio (RMS < threshold) returns True. Assert speech audio (RMS > threshold) returns False. |
tests/test_audio_padding.py | Unit tests for ring buffer in padding.py. Assert prepend_padding() prepends exactly pre_speech_padding_ms ms of prior audio. Assert ring buffer wraps correctly. |
tests/test_remote_agent.py | Unit test for agent.py JSON-RPC handler. Mock remote/inject.py. Assert inject(text) call triggers the mock injector with the correct text. |
Create the test fixture files: - tests/fixtures/disfluency/corpus.json — 100 entries as described in the eval plan. - tests/fixtures/commands/command_phrases.json — 50 entries, one per command from FR-003.3. - tests/fixtures/commands/dictation_corpus.txt — 500 words of prose. - tests/fixtures/streaming/ — at minimum 3 WAV files (synthetic, 16 kHz, float32, 3 s). - tests/fixtures/accessibility/calibration_utterances/ — 20 WAV files (synthetic, 16 kHz, float32, 3 s). - tests/fixtures/accessibility/rms_stats.json — expected RMS statistics for the 20 fixtures.
Pass/Fail Criteria¶
All of the following must be true before reporting complete:
| ID | Criterion |
|---|---|
| M-001-LAN | SSH tunnel injection latency ≤ 500 ms median on LAN |
| M-001-WAN | SSH tunnel injection latency ≤ 800 ms median on WAN |
| M-002-TTFP | Time-to-first-partial ≤ 600 ms median on streaming fixtures |
| M-002-COR | Correction operation latency ≤ 200 ms with 50 chars injected |
| M-002-CANCEL | Zero partial chars remain after cancel |
| M-003-PREC | Command grammar precision ≥ 90% on 50-command phrase set |
| M-003-FPR | Zero false-positive commands on 500-word dictation corpus |
| M-003-RT | grammar.classify() runtime ≤ 5 ms |
| M-004-RT | disfluency.filter_transcript() runtime ≤ 10 ms |
| M-004-PNID | Proper nouns and code identifiers unchanged by filter |
| M-005-ENR | Enrollment wizard produces vad_threshold within ±15% of expected |
| R-001 | test_config.py still passes (no schema breakage) |
| R-002 | test_ipc_protocol.py still passes (no IPC breakage) |
| R-003 | test_hold_detector.py still passes |
| R-004 | test_auto_inject.py still passes |
Version Bump¶
After all tests pass, update the version in two places:
pyproject.toml: changeversion = "0.2.4"toversion = "0.3.0".src/yazses/__init__.py: set__version__ = "0.3.0".
Verify: uv run yazses --version outputs YazSes 0.3.0.
Run uv run pytest tests/ -v and confirm all tests pass before reporting complete.