diff --git a/AGENTS.md b/AGENTS.md index f4f1114..89333c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ skills/ 71 built-in skill modules docs/ API.md, MCP_HTTP_SETUP.md, CONTEXT_REPORT.md, design docs ``` -Other engine modules (`codec_overlays`, `codec_metrics`, `codec_logging`, `codec_gdocs`, `codec_google_auth`, `codec_cdp`, `codec_llm_proxy`, `codec_retry`, `codec_alerts`, `codec_search`, `codec_textassist`, `codec_keyboard`, `codec_watcher`, `codec_watchdog`) are internal helpers — read them when you need them, but they're not part of the navigation surface for an agent making structural changes. +Other engine modules (`codec_overlays`, `codec_metrics`, `codec_logging`, `codec_gdocs`, `codec_google_auth`, `codec_cdp`, `codec_llm_proxy`, `codec_retry`, `codec_alerts`, `codec_search`, `codec_textassist`, `codec_watcher`, `codec_watchdog`) are internal helpers — read them when you need them, but they're not part of the navigation surface for an agent making structural changes. (Keyboard handling — wake word, F13 toggle, F18 voice, double-tap — lives **inline in `codec.py`** in the `codec` PM2 process; the old standalone `codec_keyboard.py` was deleted as a dead duplicate per A-8.) ## 3. Agent + Crew runtime diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edb8472..449eba0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,9 +55,8 @@ Drop it in `~/.codec/skills/` — CODEC loads it on restart. ## Project Structure ``` -codec.py — Entry point (imports modules) +codec.py — Entry point + inline keyboard listener (wake word, F13/F18, double-tap) codec_config.py — Configuration and constants -codec_keyboard.py — Keyboard listener and input handling codec_dispatch.py — Skill matching and dispatch codec_agent.py — LLM agent session builder codec_overlays.py — Tkinter overlay popups diff --git a/codec_keyboard.py b/codec_keyboard.py deleted file mode 100644 index fd17d76..0000000 --- a/codec_keyboard.py +++ /dev/null @@ -1,398 +0,0 @@ -"""CODEC Keyboard — listener, wake word, recording, double-tap shortcuts""" -import os -import re -import time -import tempfile -import subprocess -import threading -import logging - -from pynput import keyboard as kb - -from codec_config import ( - KEY_TOGGLE, KEY_VOICE, KEY_TEXT, - WAKE_WORD, WAKE_PHRASES, WAKE_ENERGY, WAKE_CHUNK_SEC, WHISPER_URL, - cfg, clean_transcript, -) - -log = logging.getLogger('codec') - -# Lock for thread-safe mutation of shared state fields -_state_lock = threading.Lock() - - - -def start_keyboard_listener(state, ctx): - """ - Start all keyboard listeners and the wake word thread. - - state: shared mutable dict (active, recording, rec_proc, audio_path, - last_f13, last_minus, last_star, last_plus, screen_ctx, doc_ctx, - rec_overlay) - ctx: dict of callbacks: - push, dispatch, audit, transcribe, close_session, - show_overlay, show_toggle_overlay, show_recording_overlay, - show_processing_overlay, - do_text, do_screenshot_question, do_document_input - """ - push = ctx['push'] - dispatch = ctx['dispatch'] - audit = ctx['audit'] - transcribe = ctx['transcribe'] - close_session = ctx['close_session'] - show_overlay = ctx['show_overlay'] - show_toggle_overlay = ctx['show_toggle_overlay'] - show_recording_overlay = ctx['show_recording_overlay'] - show_processing_overlay = ctx['show_processing_overlay'] - do_text = ctx['do_text'] - do_screenshot_question = ctx['do_screenshot_question'] - do_document_input = ctx['do_document_input'] - - # ── Recording start/stop ────────────────────────────────────────────────── - - def do_start_recording(): - tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - state["audio_path"] = tmp.name - tmp.close() - rec = subprocess.Popen( - ["sox", "-t", "coreaudio", "default", "-r", "16000", "-c", "1", - "-b", "16", "-e", "signed-integer", state["audio_path"]], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - state["rec_proc"] = rec - log.info("Recording...") - - def do_stop_voice(): - audio = state.get("audio_path") - rec = state.get("rec_proc") - if rec: - try: - rec.terminate() - rec.wait(timeout=3) - except (OSError, subprocess.SubprocessError) as e: - log.debug(f"Recording process cleanup failed: {e}") - state["rec_proc"] = None - with _state_lock: - state["recording"] = False - if not audio or not os.path.exists(audio): - return - if os.path.getsize(audio) < 1000: - try: - os.unlink(audio) - except OSError as e: - log.debug(f"Failed to remove short audio file: {e}") - return - log.info("Transcribing...") - if state.get('rec_overlay'): - try: - state['rec_overlay'].terminate() - except OSError as e: - log.debug(f"Recording overlay cleanup failed: {e}") - state['rec_overlay'] = None - push(lambda: show_processing_overlay('Transcribing...', 4000)) - task = transcribe(audio) - if task: - task = clean_transcript(task) - if not task: - log.info("No speech detected") - return - log.info(f"Heard: {task}") - # Relevance + TTL gate (mirrors codec.py): - # - skip screen context for trivial intents (math/time/etc.) - # - drop screen context older than 120s - ctx = state.get("screen_ctx", "") - ts = state.get("screen_ctx_ts", 0.0) - if ctx: - stale = ts and (time.time() - ts) > 120.0 - trivial = bool(re.match( - r"^\s*(?:\d+\s*[+\-*/x×÷]\s*\d+|what\s*time|time\s*(?:is\s*it|now)?" - r"|bitcoin\s*(?:price)?|btc\s*price|weather|calculate\s+" - r"|speed\s*test|ping|hello|hi|hey|status|health|uptime)\b", - task or "", re.IGNORECASE)) - if stale: - log.info(f"Screen context expired ({int(time.time()-ts)}s old) — discarding") - state["screen_ctx"] = "" - state["screen_ctx_ts"] = 0.0 - elif trivial: - log.info("Trivial task — skipping screen context injection") - else: - task = task + " [SCREEN CONTEXT: " + ctx[:800] + "]" - state["screen_ctx"] = "" - state["screen_ctx_ts"] = 0.0 - dispatch(task) - - # ── Wake word listener ──────────────────────────────────────────────────── - - def wake_word_listener(): - import sounddevice as sd - import numpy as np - import soundfile as sf - import requests as req_wake - sample_rate = 16000 # Whisper target rate - - # ── Detect native device sample rate to avoid CoreAudio errors ─────── - try: - dev_info = sd.query_devices(sd.default.device[0], 'input') - device_rate = int(dev_info['default_samplerate']) - except Exception: - device_rate = sample_rate - need_resample = device_rate != sample_rate - capture_rate = device_rate if need_resample else sample_rate - chunk_samples = int(WAKE_CHUNK_SEC * capture_rate) - if need_resample: - log.info(f"Wake: device rate {device_rate}Hz, will resample to {sample_rate}Hz") - - def _resample(audio_data, from_rate, to_rate): - """Simple linear resample from from_rate to to_rate.""" - if from_rate == to_rate: - return audio_data - ratio = to_rate / from_rate - n_out = int(len(audio_data) * ratio) - indices = np.linspace(0, len(audio_data) - 1, n_out).astype(int) - return audio_data[indices] - - # ── Smoothed energy state (Fazm-inspired decay) ────────────────────── - DECAY_RATE = 0.85 # smoothing decay per chunk - NOISE_FLOOR = 30.0 # absolute floor — ignore mic noise below this - MIN_SPEECH_FRAC = 0.12 # at least 12% of samples must be above threshold - CONFIDENCE_FLOOR = -1.0 # reject Whisper segments with avg_logprob below this - smoothed_energy = 0.0 - log.info(f"Wake word listener started (capture={capture_rate}Hz, whisper={sample_rate}Hz)") - while True: - if not WAKE_WORD or state["recording"] or not state["active"]: - time.sleep(0.3) - continue - try: - audio = sd.rec(chunk_samples, samplerate=capture_rate, channels=1, dtype='int16') - sd.wait() - # Resample to 16kHz for Whisper if device rate differs - if need_resample: - audio = _resample(audio, capture_rate, sample_rate) - - # ── 1. Smoothed energy gate ─────────────────────────────────── - raw_energy = float(np.abs(audio).mean()) - smoothed_energy = max(raw_energy, smoothed_energy * DECAY_RATE) - if smoothed_energy < max(WAKE_ENERGY, NOISE_FLOOR): - continue - - # ── 2. Minimum speech duration (≥12% of chunk above threshold) - speech_fraction = float(np.mean(np.abs(audio) > WAKE_ENERGY * 0.4)) - if speech_fraction < MIN_SPEECH_FRAC: - log.debug(f"Wake: speech too short ({speech_fraction:.0%}), skipping") - continue - - tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - tmp.close() - sf.write(tmp.name, audio, sample_rate) - try: - with open(tmp.name, "rb") as f: - r = req_wake.post( - WHISPER_URL, - files={"file": ("wake.wav", f, "audio/wav")}, - data={"model": "mlx-community/whisper-large-v3-turbo", "language": "en"}, - timeout=10) - if r.status_code != 200: - log.warning(f"Wake: Whisper returned HTTP {r.status_code}") - if r.status_code == 200: - resp_data = r.json() - text = resp_data.get("text", "").lower().strip() - - # ── 3. Confidence filter from Whisper segments ──────── - segments = resp_data.get("segments", []) - if segments: - avg_logprob = sum(s.get("avg_logprob", -0.5) for s in segments) / len(segments) - if avg_logprob < CONFIDENCE_FLOOR: - log.info(f"Wake: low confidence rejected (logprob={avg_logprob:.2f}): '{text}'") - continue - - wake_phrases_lower = [p.lower() for p in WAKE_PHRASES] - if any(phrase in text for phrase in wake_phrases_lower): - command = text - for phrase in wake_phrases_lower: - command = command.replace(phrase, "").strip() - noise_words = ['music', 'yeah', 'baby', 'oh', 'la', 'da', 'na', 'hmm', 'ooh', 'ah', 'uh'] - - def _is_noise(txt): - words = txt.lower().split() - if len(words) < 2: - return True - real = [w for w in words if len(w) > 2 and w not in noise_words] - return len(real) < 2 # tightened: require ≥2 real words - - command = clean_transcript(command) or command - if len(command) > 3 and not _is_noise(command): - log.info(f"Wake + command: {command}") - audit("WAKE_CMD", command[:200]) - push(lambda: show_overlay('Heard you!', '#E8711A', 1500)) - push(lambda cmd=command: dispatch(cmd)) - elif len(command) > 3: - log.info(f"Wake noise rejected: {command}") - audit("WAKE_NOISE", command[:200]) - else: - log.info("Wake word detected! Listening...") - push(lambda: show_overlay('Listening...', '#E8711A', 5000)) - full_audio = sd.rec(int(8 * capture_rate), samplerate=capture_rate, channels=1, dtype='int16') - sd.wait() - if need_resample: - full_audio = _resample(full_audio, capture_rate, sample_rate) - tmp2 = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - tmp2.close() - sf.write(tmp2.name, full_audio, sample_rate) - task = transcribe(tmp2.name) - if task: - task = clean_transcript(task) - if task and not _is_noise(task): - log.info(f"Heard: {task}") - audit("WAKE_TASK", task[:200]) - push(lambda t=task: dispatch(t)) - elif task: - log.info(f"Post-wake noise rejected: {task}") - audit("WAKE_NOISE", task[:200]) - except Exception as e: - log.warning(f"Wake word transcription/dispatch failed (utterance skipped): {e}") - finally: - try: - os.unlink(tmp.name) - except OSError as e: - log.debug(f"Failed to remove temp wake audio: {e}") - except Exception as e: - log.warning(f"Wake word listener error: {e}") - time.sleep(0.5) - time.sleep(0.1) - - # ── Keyboard handlers ───────────────────────────────────────────────────── - - def on_press(key): - now = time.time() - if key == KEY_TOGGLE: - # Mac F13 fires on_press for both keyDown and keyUp (pynput quirk). - # Use a 1.5-second cooldown to prevent the second event from re-toggling. - if now - state["last_f13"] < 1.5: - return - state["last_f13"] = now - if state["active"]: - with _state_lock: - state["active"] = False - push(lambda: show_toggle_overlay(False, '')) - push(close_session) - log.info("OFF") - try: - with open(os.path.expanduser("~/.codec/overlay_events.jsonl"), "a") as _f: - _f.write('{"type":"toggle_off"}\n') - except Exception as e: - log.warning(f"Toggle off overlay event write failed: {e}") - else: - with _state_lock: - state["active"] = True - push(lambda: show_toggle_overlay( - True, - cfg.get('key_voice', 'f18').upper() + '=voice ' + - cfg.get('key_text', 'f16').upper() + '=text **=screen ++=doc --=chat' - )) - log.info("ON -- " + cfg.get("key_voice", "f18").upper() + - "=voice | " + cfg.get("key_text", "f16").upper() + - "=text | *=screen | +=doc") - try: - with open(os.path.expanduser("~/.codec/overlay_events.jsonl"), "a") as _f: - _f.write('{"type":"toggle_on"}\n') - except Exception as e: - log.warning(f"Toggle on overlay event write failed: {e}") - return - if not state["active"]: - return - if key == KEY_TEXT: - if not state["recording"]: - push(do_text) - return - if key == KEY_VOICE: - now_v = time.time() - _kv_label = cfg.get('key_voice', 'f18').upper() - if not state["recording"]: - # First tap — start normal hold-to-record - with _state_lock: - state["recording"] = True - state["ptt_locked"] = False - state["last_f18_press"] = now_v - try: - subprocess.run(["pkill", "-f", "C O D E C"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (OSError, subprocess.SubprocessError) as e: - log.debug(f"Failed to kill stale TTS process: {e}") - push(do_start_recording) - state['rec_overlay'] = show_recording_overlay(_kv_label) - elif not state.get("ptt_locked"): - # Second tap while recording (not yet locked) - if now_v - state.get("last_f18_press", 0.0) < 0.5: - # Double-tap within 0.5s → lock mode - with _state_lock: - state["ptt_locked"] = True - state["last_f18_press"] = 0.0 - if state.get('rec_overlay'): - try: - state['rec_overlay'].terminate() - except OSError as e: - log.debug(f"Recording overlay cleanup failed (PTT lock): {e}") - state['rec_overlay'] = show_overlay( - '\U0001f534 REC LOCKED \u2014 tap ' + _kv_label + ' to stop', '#ff3b3b', 0) - log.info("PTT locked") - else: - # Tap while locked → stop recording - with _state_lock: - state["ptt_locked"] = False - if state.get('rec_overlay'): - try: - state['rec_overlay'].terminate() - except OSError as e: - log.debug(f"Recording overlay cleanup failed (PTT stop): {e}") - state['rec_overlay'] = None - push(do_stop_voice) - return - if hasattr(key, 'char') and key.char == '*': - if now - state["last_star"] < 0.5: - log.info("Star x2 -- screenshot mode") - push(do_screenshot_question) - state["last_star"] = 0.0 - return - state["last_star"] = now - return - if hasattr(key, 'char') and key.char == '+': - if now - state.get("last_plus", 0.0) < 0.5: - log.info("Plus x2 -- document mode") - push(do_document_input) - state["last_plus"] = 0.0 - return - state["last_plus"] = now - return - if hasattr(key, 'char') and key.char == '-': - if now - state.get("last_minus", 0.0) < 0.5: - log.info("Minus x2 -- live chat mode") - voice_url = cfg.get("voice_url", "http://localhost:8090/voice?auto=1") - push(lambda: show_overlay('Live Chat connecting...', '#E8711A', 3000)) - audit("LIVECHAT", voice_url) - subprocess.Popen(["open", "-a", "Google Chrome", voice_url]) - state["last_minus"] = 0.0 - return - state["last_minus"] = now - return - - def on_release(key): - if key == KEY_VOICE and state["recording"] and not state.get("ptt_locked"): - if state.get('rec_overlay'): - try: - state['rec_overlay'].terminate() - except OSError as e: - log.debug(f"Recording overlay cleanup failed (key release): {e}") - state['rec_overlay'] = None - push(do_stop_voice) - - # ── Start threads and listener loop ────────────────────────────────────── - - if WAKE_WORD: - threading.Thread(target=wake_word_listener, daemon=True).start() - - while True: - try: - with kb.Listener(on_press=on_press, on_release=on_release) as listener: - listener.join() - except Exception as e: - log.warning(f"Listener restarting: {e}") - time.sleep(0.5) diff --git a/docs/audits/PHASE-1-CODE-QUALITY.md b/docs/audits/PHASE-1-CODE-QUALITY.md index e5166b7..ae16736 100644 --- a/docs/audits/PHASE-1-CODE-QUALITY.md +++ b/docs/audits/PHASE-1-CODE-QUALITY.md @@ -85,6 +85,8 @@ Both scan `SKILLS_DIR` independently, so a skill file is loaded twice in differe ### A-8 — `codec_keyboard.py` (398 LOC) is dead in production [MEDIUM] **Location:** `codec_keyboard.py` (entire file). `start_keyboard_listener` defined at line 25. + +> **Closed by PR-3 A-8** (branch `fix/pr3-a8-delete-codec-keyboard`) via option (b) — delete. **Verify-first evidence:** (1) no production importer — only `tests/test_full_product_audit.py` + `tests/test_transcript.py` referenced it; (2) no PM2 entry runs it — `ecosystem.config.js` has no `codec_keyboard.py` script; the live keyboard path is inline in `codec.py` (`on_press`@788, `on_release`@858, `keyboard.Listener`@919) in the `codec` process; (3) `clean_transcript` is *defined* in `codec_config.py:841` (codec_keyboard only re-imported it). **Chose delete over the audit's option (a) "migrate to it":** option (a) would swap the live, battle-tested core-UX keyboard handler for an unused module that's nearly untestable in CI (no real pynput headless) — a regression risk on F13/F18/wake-word for an aesthetic gain, violating "never break working code." Deleted `codec_keyboard.py` (398 LOC); redirected `test_full_product_audit.py::TestKeyboard` to assert codec.py's live handlers + F13 debounce ≥1.0s + that the dead module stays gone; simplified `test_transcript.py` to import `clean_transcript` from `codec_config`. Note: the `overlay_events.jsonl` toggle-logging was a codec_keyboard-only feature that was never live (codec.py's handler doesn't write it) — no production behavior lost. AGENTS.md §2 + CONTRIBUTING.md updated. **Description:** `start_keyboard_listener` is never imported except by `tests/test_full_product_audit.py` and `tests/test_transcript.py` (which only verifies callable existence and imports `clean_transcript`). Production keyboard handling lives inline in `codec.py:1015-1097` (`on_press`/`on_release`) and `codec.py:1141-1148` (`keyboard.Listener` startup). Two implementations of wake-word + double-tap detection. **Impact:** ~398 LOC of unused engine module. Confusing for anyone trying to understand "where does F18 get handled." **Recommended fix:** Either (a) migrate `codec.py:on_press/on_release` to use `start_keyboard_listener` (so the module becomes the canonical implementation), or (b) delete `codec_keyboard.py` and move `clean_transcript` to `codec_config.py` where it's already imported from. Option (a) is the bigger improvement because the codec_keyboard implementation appears to be the cleaner one. diff --git a/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md b/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md index 9700f1a..b771943 100644 --- a/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md +++ b/docs/audits/PHASE-1-CONSOLIDATED-TRIAGE.md @@ -244,7 +244,8 @@ Mirror the Intake Phase 3 wave pattern. 7 waves planned; sizes are PR-counts, NO - PR-3F (optional, large): A-19 — bridge unification (iMessage + Telegram → `BridgeRouter`) - PR-3G: small misc ✅ (branch `fix/pr3g-small-misc-cleanup`) — closed A-9 (DISABLED overlay, ~90 LOC), A-10 (run_session_module, 33 LOC + orphan `import sys`), A-14 (close_session shadow import), A-18 (9 unused Pydantic models + dead typing import). A-13 (dashboard pattern blocker) verified **already closed by PR-2C**. 6 regression tests; zero net-new ruff (net −); full suite 1344 passing. **Deferred from this batch (each needs its own focused PR):** A-8 (codec_keyboard.py 398 LOC — verify-first delete-or-migrate decision), A-15 (config_version — additive migration feature touching `load_config`), A-20 (inline sqlite in the live dispatch path — reliability fix needing a CodecMemory method). - A-15: config schema versioning ✅ (branch `fix/pr3-a15-config-versioning`; `CONFIG_SCHEMA_VERSION=1` + migration ladder + idempotent atomic write-back in `load_config`; never creates-on-missing or overwrites-corrupt; 12 tests; zero net-new ruff; full suite 1356 passing). -- A-20: inline-sqlite reliability fix ✅ (branch `fix/pr3-a20-inline-sqlite`; added `codec_core._db_connect()` with WAL+busy_timeout + `update_session_response()`; replaced codec.py's inline lock-prone UPDATE; retrofitted all 4 codec_core session connects; removed now-unused sqlite3/DB_PATH imports; 9 tests; net-negative ruff; full suite 1365 passing). **Still deferred: A-8** (codec_keyboard.py 398 LOC — verify-first delete-or-migrate). +- A-20: inline-sqlite reliability fix ✅ (branch `fix/pr3-a20-inline-sqlite`; added `codec_core._db_connect()` with WAL+busy_timeout + `update_session_response()`; replaced codec.py's inline lock-prone UPDATE; retrofitted all 4 codec_core session connects; removed now-unused sqlite3/DB_PATH imports; 9 tests; net-negative ruff; full suite 1365 passing). +- A-8: codec_keyboard.py deleted ✅ (branch `fix/pr3-a8-delete-codec-keyboard`; verify-first confirmed dead — no prod importer, no PM2 entry, live keyboard path inline in codec.py, clean_transcript lives in codec_config; chose delete over migrate to avoid swapping battle-tested core-UX code for an untested module; redirected TestKeyboard to codec.py; full suite passing). **All eight PR-3G-cluster findings now closed** (A-8/9/10/13/14/15/18/20). **Rationale:** Audit A is the broadest in scope — clean up patterns and dead code. PR-3A alone deletes ~730 LOC and improves the first-impression of the most-read file. diff --git a/tests/test_full_product_audit.py b/tests/test_full_product_audit.py index dadb564..1a71d54 100644 --- a/tests/test_full_product_audit.py +++ b/tests/test_full_product_audit.py @@ -1057,26 +1057,33 @@ def test_session_uses_centralized_dangerous(self): # ============================================================================ class TestKeyboard: - """Test keyboard module.""" + """Test the LIVE keyboard path. - def test_keyboard_imports(self): - from codec_keyboard import start_keyboard_listener - assert callable(start_keyboard_listener) + A-8 (PR-3): the unused `codec_keyboard.py` module (398 LOC, only ever + imported by tests) was deleted. Production keyboard handling is inline + in codec.py (the `codec` PM2 process). These tests now pin codec.py's + real handlers + the F13 debounce invariant.""" + + def test_live_keyboard_handlers_in_codec(self): + """codec.py owns the live on_press/on_release + keyboard.Listener.""" + code = Path(REPO, "codec.py").read_text() + assert "def on_press" in code + assert "def on_release" in code + assert "keyboard.Listener" in code def test_f13_debounce(self): - """F13 debounce should be >= 1.0 seconds.""" - code = Path(REPO, "codec_keyboard.py").read_text() - # Find the debounce check + """F13 debounce should be >= 1.0 seconds (codec.py live handler).""" + code = Path(REPO, "codec.py").read_text() match = re.search(r'last_f13.*?<\s*([\d.]+)', code) - assert match, "F13 debounce not found" + assert match, "F13 debounce not found in codec.py" debounce = float(match.group(1)) assert debounce >= 1.0, f"F13 debounce too short: {debounce}s" - def test_overlay_events_path(self): - """Should use ~/.codec/ not /tmp/.""" - code = Path(REPO, "codec_keyboard.py").read_text() - assert "/tmp/" not in code, "Still using /tmp/ path" - assert "~/.codec/" in code or "overlay_events" in code + def test_codec_keyboard_module_removed(self): + """The dead duplicate module must stay gone (A-8).""" + assert not Path(REPO, "codec_keyboard.py").exists(), ( + "codec_keyboard.py was deleted as dead code (A-8) — must not return" + ) # ============================================================================ diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 6f1de33..fb197cf 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -7,10 +7,9 @@ def test_import(): - try: - from codec_keyboard import clean_transcript - except ImportError: - from codec_config import clean_transcript + # A-8 (PR-3): clean_transcript is defined in codec_config; the old + # codec_keyboard re-export was deleted along with that dead module. + from codec_config import clean_transcript assert clean_transcript is not None