Interruptible speech and short spoken replies in voice mode
Two live issues in voice mode got fixed: long TTS replies that blocked until done, and router output formatted as markdown read verbatim by the speech engine.
Barge-in during playback
The say() function in tools/speak.py was blocking in sd.wait() until the entire audio stream finished. I added a new _wait_or_interrupt() function that polls every 50ms while the output stream is active. Ctrl+space stops playback and returns "interrupted", Esc stops playback and returns "exit", and normal completion returns None. The keyboard library is imported with try/except so if it's unavailable the function falls back to sd.wait() exactly as before. Keys are checked separately, not as a combo string, to match tools/listen.py.
The foreman.py wiring routes all in-loop say() calls through a local speak() wrapper that turns the "exit" return into "Leaving voice mode." and unwinds the loop. The execute() and run_command() functions now return that exit signal so an Esc mid-headline stops the whole voice loop.
Strip markdown and cap length for speech
Router denial texts arrived as screen-formatted markdown that TTS read verbatim. The weather denial took around 15 seconds. I added strip_for_speech() in tools/router.py that removes asterisks, backticks, and leading bullet markers, turns each source line into its own sentence, drops trailing list-intro punctuation, and caps output at two sentences. The foreman.py code prints the full router reply but speaks strip_for_speech() for both the denial branch and describe_capabilities.
The router system prompt now ends with: "Replies will be read aloud. Answer in at most two short plain sentences. No markdown, no bullet points, no offers to help further." The live weather denial arrived as two plain sentences after that change.
Phase 5c: local web control panel
I built a local web control panel at python foreman.py ui using stdlib http.server ThreadingHTTPServer on 127.0.0.1:3334. No build step, no npm, no framework. Plain HTML, CSS, and fetch. The page never receives secrets. Supabase and Anthropic keys stay server-side. Loopback-only bind plus no embedded secret means no auth needed.
The panel has three read-only report cards: git as a table, MemStack, and business. Each has a Refresh button. The voice picker groups voices by accent and gender: American female, American male, British female, British male, and Other languages. The learned-corrections viewer shows per-row delete and a "Nothing learned yet." empty state.
Two mutations touch only files under memory/: POST /api/voices/set persists the choice and plays a short confirmation sample, POST /api/voices/preview auditions without saving, and POST /api/corrections/delete does an atomic rewrite. No git actions. No report triggers that mutate. Typed and voice CLI modes are fully unaffected because the server is imported only inside run_ui().
Fixes from live use
Live use showed that previewing a voice also saved it, so auditioning overwrote the setting on every click. I added /api/voices/preview which synthesizes with the requested voice passed straight to model.create(), bypassing say() and active_voice(). The voice_settings.json file is untouched.
SO_REUSEADDR let a second instance silently bind alongside a stale server. I subclassed the server with allow_reuse_address=False and, on Windows, SO_EXCLUSIVEADDRUSE in server_bind. A second instance now raises OSError and hits the port-in-use message.
The preview text "This is <name>." was too short to judge a voice. PREVIEW_TEXT now reads a full morning rundown with repo counts, product names, and dollar figures, with <name> substituted at synthesis time. Set keeps the short confirmation. Audition stays interruptible via the existing _wait_or_interrupt path with ctrl+space or Esc.
A stale python foreman.py ui process from a previous session was still listening on port 3334 with pre-preview code. Windows SO_REUSEADDR let the new test server bind the same port while the old process answered, so /set worked but /preview returned 404. I diagnosed it with netstat, killed the process, re-ran verification on a clean port, then fixed the root cause with the exclusive bind so a second instance can never silently share the port.