Files
javis/docs/AUDIT.md
T
2026-10-04 15:31:44 +02:00

34 KiB
Raw Blame History

Javis — Code Audit

Scope: the main application src/javis/** (~19 k lines across 59 modules), plus pyproject.toml, requirements.txt, config.example.yaml and README.md. The satellite packages (javis-slave, *-mcp, voice-ref-curator) are only covered where they interact with the master (API, MCP).

Method

  1. Baseline tooling: ruff check --select F,E9,B (37 findings), compileall (clean), pytest (not installed in .venv, so no tests ran).
  2. Line-by-line review of every subsystem (core, audio, STT, TTS/voices, LLM, MCP, API, agent, GUI).
  3. Every finding below was re-verified against the source (and, where relevant, against the installed library versions: mcp 2.0.0, fastapi 0.141, starlette 1.6, faster-whisper 1.2.1, silero-vad 6.2.1, PySide6 6.11, qasync 0.28). Claims that did not survive verification are listed under Reviewed and rejected.

Line numbers in the findings refer to the audited revision 0246cbb6 (before the fixes).

Severity scale

Level Meaning
Critical Crash or core feature broken for a typical user
High Security weakness or significant malfunction
Medium Robustness / correctness problem under plausible conditions
Low Minor bug, hygiene, or edge case
Info Design note / recommendation, no defect

1. Architecture overview

flowchart LR
    subgraph Input
        MIC[MicStream<br/>sounddevice cb] --> WW[openWakeWord]
        MIC --> VAD[Silero VAD]
        VAD --> REC[record_utterance]
        TXT[GUI text input] --> ORCH
        SLAVE[javis-slave<br/>FastAPI /turn] --> ORCH
    end
    WW -->|wake| ORCH[Orchestrator<br/>state machine + turn lock]
    REC --> STT[faster-whisper] --> ORCH
    REC --> SPK[ECAPA speaker ID] --> ORCH
    ORCH --> QUICK[QuickEngine<br/>LLM bypass]
    ORCH --> MW[Middleware pipeline<br/>Privilege / HomeyGuard / Memory]
    MW --> LLM[Ollama client]
    LLM -->|tool calls| MCP[MCPHost<br/>stdio servers]
    QUICK --> MCP
    ORCH --> TTS[Piper / F5 backend] --> OUT[OutputStream]
    ORCH -. EventBus .-> GUI[PySide6 + qasync GUI]
  • Single event loop. Everything runs on one asyncio loop (qasync in GUI mode, asyncio.run headless). Blocking work (STT, TTS, speaker ID, PCM writes) is pushed to threads with asyncio.to_thread.
  • Turn serialisation. Orchestrator._turn_lock serialises wake-word, PTT, typed, remote (slave) turns, enrollment and timer announcements.
  • Middleware. TanStack-style hooks (on_before_llm piped, on_before_tool first-win, the rest sequential). PrivilegeMiddleware is the per-speaker ACL.
  • Tool routing. Keyword heuristics pick a subset of MCP servers per turn; a javis__load_tools meta-tool lets the model pull in un-routed servers.
  • GUI. One _MainWindow class (defined inside run_gui()) receives EventBus events through Qt signals.

2. Findings summary

ID Sev. Area Title Status
MCP-01 Critical MCP MCP SDK 2.x field names → empty tool schemas, tool errors reported as success Fixed
GUI-01 Critical GUI NameError when rendering voice-profile cards Fixed
SEC-01 High Repo / security Git remote URL embeds a personal access token Action required (owner)
AGT-01 High Agent / security Quick commands bypass speaker privileges Fixed
MCP-02 High MCP / security "Allow once" approval is remembered for the whole session Fixed
MCP-03 High MCP / security Bare server name executes that server's first tool Fixed
AGT-10 Medium Agent One invalid stored regex quick command breaks every turn Fixed
MCP-08 Medium MCP No handshake timeout; servers connected one after another Fixed
VOI-02 Medium Voices Quitting during scheduled training hangs exit and frees the lock early Fixed
AGT-02 Medium Agent Quick-command repair pairs actions with the wrong errors Fixed
AGT-03 Medium Agent Orchestrator.stop() runs twice / concurrently Fixed
AGT-04 Medium Agent State stuck in THINKING (+ endless "thinking" ticks) on early errors Fixed
AGT-05 Medium Agent / GUI Fire-and-forget tasks without strong references Fixed
AGT-06 Medium Audio out PCM writer thread keeps writing while the stream is closed Fixed
MCP-04 Medium MCP Any tool error triggers server respawn + automatic retry Fixed
MCP-05 Medium MCP Exit stacks closed from a different task than they were opened in Fixed
LLM-01 Medium LLM Streaming errors swallowed → silent empty/partial replies Fixed
AUD-01 Medium Audio in record_utterance can block forever (holding the turn lock) Fixed
API-01 Medium API / security Large bodies parsed before auth and size check Fixed
GUI-02 Medium GUI Theme registry leaks every rebuilt card widget Fixed
GUI-04 Medium GUI / security cmd /k command line built with list2cmdline Fixed
VOI-01 Medium Voices / security Imported voice archives may carry pickle checkpoints Fixed (skipped by default)
LLM-02 Low LLM History trimming can drop the in-progress turn Fixed
AGT-07 Low Agent Timer announcement can leave state SPEAKING Fixed
AGT-08 Low Agent / security Privilege check fails open on exceptions Fixed
AUD-02 Low Audio in 30 ms chunks are zero-padded to Silero's 512-sample window Fixed
AUD-03 Low Config audio.sample_rate ≠ 16000 silently breaks VAD / wake word Fixed
AUD-04 Low Audio in Mic callback can raise during shutdown Fixed
MCP-06 Low MCP One leaked stderr temp file per server start Fixed
API-02 Low API Pair-attempt table grows forever; internals leaked in errors Fixed
GUI-05 Low GUI Background tasks not cancelled on quit Fixed
CORE-01 Low Logging Log file date frozen at start; file I/O on the event loop Fixed
CORE-02 Low Config Missing validation (threshold range, YAML root type) Fixed
LINT-01 Low Hygiene Ruff F/B findings (unused imports, duplicate set items, …) Fixed
DOC-01 Low Docs README describes XTTS; code ships Piper/F5 Fixed
DOC-02 Low Packaging Unpinned mcp major version; test deps missing Fixed
AGT-09 Info Agent Substring keyword routing over-includes tool sets Open (recommendation)
ARCH-01 Info Maintainability 5.3 k-line GUI closure, duplicated TTS pipelines Open (recommendation)
API-03 Info API / security Plain-HTTP LAN API Open (recommendation)
MCP-07 Info MCP / security MCP servers inherit the full environment Open (recommendation)

3. Detailed findings

MCP

MCP-01

MCP SDK 2.x field names → empty tool schemas, tool errors reported as success — Critical

  • mcp_client/schema_bridge.py L12: getattr(tool, "inputSchema", None)
  • mcp_client/host.py L461: getattr(result, "isError", False)
  • mcp_client/host.py L261: page.next_cursor

The dependency is declared as mcp>=1.2; the venv resolved mcp 2.0.0, whose pydantic models expose snake_case attributes (input_schema, is_error, next_cursor) — the camelCase names are only serialisation aliases and are not attributes (verified: getattr(Tool(...), "inputSchema", "NOATTR") == "NOATTR").

Impact with mcp 2.x:

  1. Every MCP tool is advertised to the LLM as {"type":"object","properties":{}} — the model has to guess every argument name.
  2. isError results are never prefixed with ERROR: → failures are shown as successes (✓ in the UI), cached by the cross-turn tool cache, and the quick-command repair path never triggers.

Conversely, with mcp 1.x page.next_cursor raises AttributeError, so the code only half-works on either major version.

Fix: version-agnostic accessors (input_schema/inputSchema, is_error/isError, next_cursor/nextCursor), and the dependency is now bounded mcp>=1.2,<3.

MCP-02

"Allow once" is remembered for the whole session — High

MCPHost.call() (L399-L420) adds (server, tool) to _allow_cache after any positive approval. The GUI dialog's Always allow path already calls allow_tool(); Allow once (and a spoken "ja") therefore silently behaves like Always allow: subsequent calls of that tool — including ones with completely different, possibly destructive arguments — run without asking.

Fix: a positive answer is now remembered only for the current turn (_turn_allow_cache, cleared together with denials at every new turn); only Always allow persists.

MCP-03

Bare server name executes the server's first tool — High

validate_tool_name() (schema_bridge.py L31-L57) "recovers" a tool name without __ that equals a server name by returning that server's first tool. MCPHost.call() then executes it with whatever arguments the model sent. For a filesystem/Homey/Spotify server, the first tool can be a write/actuating tool.

Fix: no guessing. The call returns ERROR: '<name>' is not a tool. Did you mean one of: … so the model can retry with a real name.

MCP-04

Every tool exception triggers a respawn + automatic retry — Medium

host.py L425-L445: any exception from session.call_tool (including JSON-RPC errors such as invalid params, raised as McpError/MCPError) tears the server down, respawns it and repeats the call. Non-idempotent tools (play, switch, write) can run twice; heavy servers (npx …) are restarted for a simple argument error.

Fix: protocol errors are returned to the model immediately; reconnect + retry only happens for transport failures (CONNECTION_CLOSED, closed/broken streams, OSError).

MCP-05

Exit stacks closed from a different task than they were opened in — Medium

Each server's AsyncExitStack is entered inside the task that ran MCPHost.start() (the GUI _bootstrap task) and closed from another task (_shutdown, or the turn task that triggers _reconnect). The MCP SDK uses anyio task groups/cancel scopes, which must be exited by the task that entered them → RuntimeError: Attempted to exit cancel scope in a different task …, which was logged and ignored, leaving cleanup of the child process to chance.

Fix: each server is owned by a dedicated long-lived task that enters the contexts, signals readiness, waits for a stop event, and exits the contexts itself (the pattern recommended for the MCP Python SDK / anyio).

MCP-06

One leaked stderr temp file per server start — Low

The stderr NamedTemporaryFile(delete=False) is unlinked right after a successful handshake while the child process still holds the handle; on Windows unlink fails (swallowed), so every start/reconnect leaves a mcp-<name>-*.stderr.log in %TEMP% that the child keeps appending to. The stderr content (useful for debugging) is lost.

Fix: stderr goes to ~/.javis/logs/mcp-<name>.stderr.log (truncated per start).

MCP-07

MCP servers inherit the full process environment — Info

MCP-08

No handshake timeout; servers connected one after another — Medium

session.initialize() had no timeout, and MCPHost.start() connected servers sequentially inside the boot gather. One server that hangs during start-up (e.g. an npx package prompt, a dead network share) blocked "Loading models & MCP servers" forever — and with it orch.start() and the whole agent. Fix: 120 s handshake timeout per server, all servers connected concurrently.

Repository

SEC-01

Git remote URL embeds a personal access token — High

git remote -v shows https://<user>:<token>@<gitlab host>/…. The token is stored in plain text in .git/config and is printed by every git remote -v, CI log or support screenshot. (The secret-bearing config.yaml, mcp_servers.json and spotify-config.json are correctly git-ignored and were never committed.) Action for the owner: revoke/rotate the token in GitLab, then git remote set-url origin https://<gitlab host>/<group>/javis.git and let Git Credential Manager store the credential. This was not changed automatically because it affects your push setup. env = {**os.environ, **spec.env} (L231) passes e.g. JAVIS_HERMES_API_KEY to every server. This matches VS Code's behaviour and many servers need PATH, APPDATA, etc. Treat mcp_servers.json as trusted code; consider an allow-list if third-party servers are added.

Agent / orchestrator

AGT-01

Quick commands bypass speaker privileges — High

_handle_turn computes _privileges from speaker ID (L1270-L1320) but calls _try_quick_response(tr.text) (L1333) without them; _try_quick_response executes each action via self.mcp.call(...) directly (L2190-L2205), never consulting PrivilegeMiddleware. A guest/unknown speaker whose ACL denies homey__* can still run every learned Homey quick command. The quick-command repair loop also creates a TurnContext without privileges, so it runs with full rights. (Remote/slave turns do not use quick commands, so they were not affected.)

Fix: privileges are passed to _try_quick_response; if any action is not allowed the quick path is skipped (the LLM path then enforces the ACL), and the repair turn inherits the speaker's privileges.

AGT-02

Repair directive pairs actions with the wrong errors — Medium

L2220-L2223: zip(cmd.actions, errors) — errors contains only the failed actions, so with actions [A ok, B failed] the directive claims A → <B's error>. The LLM is told to repair the wrong step.

Fix: failures are collected as (action, error) pairs.

AGT-03

stop() runs twice / concurrently — Medium

run() calls stop() in its finally (L660), the GUI calls it again from _shutdown(), and run_headless() calls it again in its own finally. In GUI mode both run concurrently (the loop resumes the cancelled run() while run_until_complete(_shutdown()) executes), so MCP stacks, the mic and the HTTP clients are torn down twice in parallel.

Fix: stop() is idempotent and serialised (_stopped flag + lock); the main loop task is cancelled and awaited before teardown.

AGT-04

State stuck in THINKING (with endless ticks) on early errors — Medium

  • submit_text (L703-L716): _start_thinking_cue(), _try_quick_response(), conv.add_user() and run_start() run before the try/finally that resets state. If any raises, the state stays THINKING and the cue task loops while self.state == THINKING: tick; sleep(4) — a soft tick every 4 s until another turn happens.
  • submit_remote_turn (L785-L800): an STT failure leaves the master in THINKING.
  • _handle_turn leaked the cue task the same way when STT raised.

Fix: the whole turn body is inside try/finally (cue cancelled, state → IDLE).

AGT-05

Fire-and-forget tasks without strong references — Medium

asyncio.ensure_future(...)/create_task(...) results were discarded in ~25 places (chimes, TTS pre-warm, background quick-suggest, profiler.refine, GUI actions, and the long-lived orch.run(), training scheduler and API server tasks). The event loop keeps only weak references (see the Python docs warning on create_task), and an exception in such a task is only reported at garbage collection as "Task exception was never retrieved" — e.g. a crash

AGT-10

One invalid stored regex quick command breaks every turn — Medium

QuickEngine.add/update accepted any match_mode and any regex — including ones written by the LLM through javis__quick_add. match() then ran re.search() on every utterance; an invalid pattern raised re.error on every voice/text turn (error phrase, nothing works) until the entry was deleted by hand. Fix: modes and regexes are validated on add/update (the LLM gets an ERROR: result and can retry; the GUI shows a warning), and a bad pattern already on disk is logged and skipped. of orch.run() would have been silent.

Fix: new javis.core.tasks.spawn() keeps a strong reference until completion and logs exceptions; used everywhere a task is not awaited.

AGT-06

PCM writer thread keeps writing while the stream is closed — Medium

_speak, _speak_stream and play_pcm write audio with await asyncio.to_thread(_write_pcm, …). When the awaiting task is cancelled (turn error, shutdown, barge-in cleanup), to_thread returns immediately but the thread keeps running, while the finally block calls stream.abort()/stop()/close(). PortAudio does not support closing a stream that another thread is writing to → errors, hangs in stop() or native crashes. The writer code was also triplicated.

Fix: a single write_pcm_blocks() helper; on cancellation it raises a stop flag and waits (bounded) for the writer thread to exit before the stream is closed.

AGT-07

Timer announcement can leave state SPEAKING — Low

_on_timer_fire (L1875-L1888) calls _speak() and then set_state(IDLE) without try/finally. Fix: finally resets the state.

AGT-08

Privilege check fails open — Low

run_before_tool (middleware.py L1718-L1735) logs and ignores exceptions from every middleware. For PrivilegeMiddleware this means allow on error (e.g. a malformed allowed_tools value in index.json). Fix: an exception in the privilege middleware now denies the tool.

AGT-09

Substring keyword routing over-includes tool sets — Info

_route_tools uses k in text for single words: hell ⊂ hello, play ⊂ display, ton ⊂ Anton, and möchte/wollen (Emby set) match most German requests, so Emby schemas are attached to nearly every turn. Substring matching is deliberate for German compounds (Wohnzimmerlicht), so this is left as a recommendation: drop generic verbs (möchte, wollen, what is) from the sets, or match short words on word boundaries and keep substring matching for nouns ≥ 5 chars.

ARCH-01

Maintainability hot-spots — Info

  • gui/main_window.py (~5.3 k lines) defines the whole window class inside run_gui(), capturing config from the closure; it cannot be imported or tested in isolation. Recommended split: one module per tab + a MainWindow class taking config explicitly.
  • orchestrator.py (~3.3 k lines) embeds ~300 lines of JSON tool schemas; _speak and _speak_stream duplicate the synth→play pipeline. Recommended: agent/builtin_tools.py and a tts_pipeline.py with one producer/consumer implementation fed by a queue.

LLM

LLM-01

Streaming errors swallowed — Medium

OllamaClient.chat_streaming (L326-L362) catches every exception (connect failure, read timeout, dropped connection), logs it at DEBUG and returns whatever was received. When Ollama is down the result is an empty response, which _llm_loop interprets as "model returned nothing" and answers with nudges/loop fallbacks instead of reporting the outage. The two GeneratorExit branches are dead code (a coroutine never sees GeneratorExit from aiter_lines, and except Exception cannot catch it).

Fix: errors are logged at WARNING; if nothing was received the exception is re-raised, a partial reply is returned with a warning.

LLM-02

History trimming can drop the in-progress turn — Low

Conversation._trim() runs after every add_* call and drops whole turns from the front until under max_chars. If the current turn alone exceeds the budget (large system prompt + several 2 kB tool results), the current user message and the assistant tool-call message are dropped, leaving orphaned tool messages and a model that no longer sees the question. Fix: the newest user turn is never trimmed.

Audio / STT

AUD-01

record_utterance can block forever — Medium

capture.py L106 iterates mic.chunks() without stale_timeout. If the input device disappears mid-utterance (USB unplug, driver reset), no chunk ever arrives, the coroutine never returns and _turn_lock stays held — PTT, typed input and timer announcements are blocked until restart. Fix: a 5 s stale timeout ends the recording with what was captured.

AUD-02

Chunk size vs. Silero window — Low

The mic delivers 30 ms (480 samples) chunks; SileroVAD.process zero-pads each to 512 samples. Silero v5/v6 accepts exactly 512 samples @ 16 kHz and keeps the last 64 samples as context for the next call, so every call's context is half zeros. Fix: 32 ms chunks (512 samples) — exact Silero window; openWakeWord buffers to its own 1280-sample frame so it is unaffected.

AUD-03

Non-16 kHz sample rate silently breaks detection — Low

audio.sample_rate is configurable, but make_vad() is always built for 16 kHz and openWakeWord only supports 16 kHz. Fix: validate() forces 16000 with a warning.

AUD-04

Mic callback can raise during shutdown — Low

_cb checks loop.is_closed() and then calls call_soon_threadsafe from the PortAudio thread; if the loop closes in between, RuntimeError propagates into PortAudio. Fix: guarded.

API (slave devices)

API-01

Large bodies parsed before auth and size check — Medium

FastAPI parses the multipart body (UploadFile) before running the Depends auth dependency, and /turn reads the whole upload into memory before comparing it with max_upload_bytes (routes.py L152-L160). Any host on the LAN can make the master spool arbitrarily large bodies to disk/RAM. Fix: a small ASGI guard rejects requests whose Content-Length exceeds the limit (413) and requires a length for /turn, before any parsing.

API-02

Pair-attempt table grows forever; decoder internals leaked — Low

_pair_attempts keeps one entry per source IP forever; /turn echoed soundfile's internal decoder message; _require_device uses assert (removed under python -O). Fix: stale entries are pruned; decode errors return a generic message (details stay in the log); explicit check instead of assert. Turn errors are still reported to the (authenticated) device, truncated to 200 characters, because the satellite shows them to the user.

API-03

Plain-HTTP LAN API — Info

Bearer API keys travel unencrypted and the server binds 0.0.0.0 when enabled. Keys are hashed at rest and compared in constant time (good). Recommendation: restrict the Windows firewall rule to the private profile/subnet, or terminate TLS in a reverse proxy (Caddy) if the LAN is not trusted.

GUI

GUI-01

NameError when rendering voice-profile cards — Critical

_make_voice_card (main_window.py L2355-L2398) uses name and v that are never defined (ruff F821) — the card's QVBoxLayout and name = profile["name"] were lost. As soon as one speaker profile exists, opening the Intelligence tab (which refreshes on every activation) raises inside the Qt slot and the voice panel never renders.

Fix: restored the layout and the name lookup.

GUI-02

Theme registry leaks every rebuilt card widget — Medium

_th() appends (widget, factory) to self._themed forever. Memory, quick-command and voice cards are rebuilt on every Intelligence-tab activation (≈5 entries per card), so the list grows without bound and retheme() iterates thousands of deleted widgets. Fix: dead entries are pruned (shiboken6.isValid) with an amortised threshold.

GUI-04

cmd /k command line built with list2cmdline — Medium

Training/export are launched with Popen(["cmd", "/k", subprocess.list2cmdline(cmd)]) (L4036-L4042, L4102-L4108). list2cmdline quotes for the MSVC runtime, not for cmd.exe: an argument without spaces containing &, |, ^ (voice names from imported archives, the free-text base-checkpoint path) is executed as an extra command; and the outer Popen list re-escapes inner quotes (\"), which breaks interpreter paths with spaces. Fix: every argument is double-quoted for cmd.exe (arguments containing " are rejected) and the command string is passed verbatim.

GUI-05

Background tasks not cancelled on quit — Low

After loop.run_forever() returns, only orch.stop() is awaited; the training scheduler and API server tasks stay pending → "Task was destroyed but it is pending" and the uvicorn socket is never closed cleanly. Fix: tracked and cancelled.

Voices / TTS

VOI-02

Quitting during scheduled training hangs exit and frees the lock early — Medium

The overnight scheduler runs run_pipeline() via asyncio.to_thread; the thread waits on the training subprocess for hours. Cancelling the scheduler task (quit) released the training lock immediately while the thread kept training, and at interpreter exit Python joins executor threads — so Javis hung on quit until training finished. Fix: cancellation calls piper_train.request_stop(), which terminates the child process (_run now polls instead of blocking in wait()); the lock is only released once the worker has actually finished.

VOI-01

Imported voice archives may carry pickle checkpoints — Medium (trust)

.javisvoice import (voices/io.py) correctly blocks zip-slip and oversize archives, but it extracts training/** including PyTorch-Lightning *.ckpt files and base_checkpoint.txt. Training/export later load those checkpoints (pickle-based) → importing an archive from an untrusted source and pressing Train executes arbitrary code. Fix: checkpoint/pickle files (.ckpt, .pt, .pth, .pkl) are skipped on import unless explicitly allowed, with a log warning.

Core / config / logging

CORE-01

Log file date frozen at start; file I/O on the event loop — Low

log_dir / f"javis_{datetime.now():%Y%m%d}.log" is evaluated once; a process running for a week writes into the start-day file (size rotation only). File writes run synchronously on the event-loop thread, and loguru's default diagnose=True prints local variable values in tracebacks (e.g. dicts holding MCP server env tokens). Fix: loguru's {time:YYYYMMDD} filename placeholder with a midnight-or-10 MB rotation predicate, enqueue=True (writes on a background thread) and diagnose=False on both sinks.

CORE-02

Missing validation — Low

speaker_id.threshold and speaker_id.refine_margin were not range-checked; a YAML file whose root is not a mapping crashed with AttributeError. Fix: validated.

LINT-01

Ruff findings — Low

Unused imports (AgentState, PAL_LIGHT, json, pathlib, csv, asyncio, parse_qualified_name), duplicate set literals (B033 in _SLAVE_KEYWORDS, _EMBY_KEYWORDS, _SEARCH_KEYWORDS, _RECALL_STOPWORDS), unused loop variable, f-string without placeholders, missing exception chaining. Fix: cleaned up.

Docs / packaging

DOC-01

README describes Coqui XTTS v2 and requires Python ≤ 3.11 "because of XTTS"; the code ships Piper (default) and optional F5-TTS. Fix: README updated.

DOC-02

mcp>=1.2 allowed a breaking major (see MCP-01); pytest was not installed so the test suite could not run. Fix: mcp>=1.2,<3; pip install -e .[dev] documented.


4. Reviewed and rejected (false positives)

These were raised during the automated pass and rejected after checking the code:

Claim Why rejected
core/events.py is truncated File is complete; publish() ends after the logging loop.
Global hotkey never unregistered run_gui calls hk.uninstall() after the loop stops.
GUI quits without orch.stop() _shutdown() is run with run_until_complete. (It ran twice — see AGT-03.)
Startup-dialog mic meter touches Qt from the audio thread Callback only stores a float; the QTimer tick on the GUI thread updates the bar.
Timer restore schedules wrong times Remaining time is recomputed from the absolute fire_at; correct.
Fraction(...).limit_denominator(1000) causes pitch shift 44100→16000 = 160/441 exactly; common rates reduce exactly.
Wake-word download endpoint path traversal Path comes from the owner's config, not the client.
asyncio.set_event_loop() deprecated Not deprecated on 3.10–3.13; required by qasync.
blocksize counted in samples vs frames sounddevice blocksize is frames; code is correct.
_abort_early is a "marker" spoken by accident It is the intended spoken abort reason.

5. Verification

Check Result
ruff check src/javis --select F,E9,B 37 findings → 0 (B008 is the idiomatic FastAPI Depends pattern and is ignored)
pytest tests 21 existing + 18 new regression tests (tests/test_audit_fixes.py) — 39 passed
Offscreen GUI smoke test (tests/_retheme_check.py) Window builds, theme round-trip OK, voice-profile card renders (GUI-01)
Live MCP smoke test (local duckduckgo-mcp) 3 tools with full parameter schemas (MCP-01), bare server name rejected (MCP-03), clean stop from a different task (MCP-05), stderr log under logs/ (MCP-06)
Logging New file javis_YYYYMMDD.log created, enqueue sink flushes

6. Improvements and best practices

Researched against primary documentation; ✅ = implemented in this pass, 📌 = recommendation.

# Topic Best practice (source) Javis status
1 Background tasks "Save a reference to the result of [create_task] … The event loop only keeps weak references to tasks"; prefer asyncio.TaskGroup for structured concurrency (Python docs – Creating tasks). ✅ core.tasks.spawn() keeps references + logs failures. 📌 After moving to Python ≥ 3.11, run per-turn helpers (chimes, cue, producer) in a TaskGroup owned by the turn.
2 Cancellation hygiene Use try/finally for clean-up and re-raise CancelledError (Python docs – Task cancellation). ✅ Turn bodies restructured (AGT-04), PCM writer waits for its thread (AGT-06), scheduler propagates cancellation (VOI-02).
3 MCP client lifecycle "Let the async with block own the connection"; "Check result.is_error rather than expecting a failing tool to raise"; SDK ≥ 2.0 uses snake_case fields (MCP – Build a client). ✅ Owner task per server (MCP-05), is_error/input_schema compat (MCP-01). 📌 Consider migrating to the SDK 2.x Client wrapper once 1.x support is dropped.
4 Tool permissions "Be cautious with tool permissions … connect only to servers and executable commands you trust" (same source). ✅ Allow-once is per turn (MCP-02), no guessed tools (MCP-03), privileges on quick commands (AGT-01), fail-closed ACL (AGT-08).
5 Streaming tool calls Ollama streams content and tool calls since May 2025 with a new incremental tool parser (Ollama blog – Streaming responses with tool calling). 📌 _llm_loop still uses stream=false whenever tools are attached (comment cites old Ollama behaviour). Switching tool rounds to chat_streaming(tools=…) would start TTS on the first sentence of tool-free answers, a noticeable latency win. Needs a test pass with the models you use.
6 Context window Ollama notes that a context of 32 k or higher improves tool-calling quality (same source); num_ctx is a per-request option (Ollama API – chat). 📌 Default llm.num_ctx is 16 384 while the comment in config.py recommends 32 768 for 20+ tools; Conversation.max_chars (24 k chars ≈ 6 k tokens) is independent of num_ctx — derive it from num_ctx.
7 keep_alive Accepts a duration string ("5m") or number; 0 unloads (Ollama API – chat). ✅ Example config no longer suggests the invalid "infinity" (use -1). 📌 Allow duration strings in LLMConfig.keep_alive.
8 VAD framing Silero VAD supports 8/16 kHz; chunks of 30+ ms (silero-vad); the v5+ models take a fixed 512-sample window at 16 kHz. ✅ 32 ms (512-sample) mic chunks (AUD-02); 16 kHz enforced (AUD-03).
9 Wake-word robustness Feed multiples of 80 ms; enable the built-in Silero vad_threshold gate to cut false activations; tune the threshold per room; consider custom verifier models for known voices (openWakeWord README). ✅ New opt-in wake_word.vad_gate. 📌 A verifier model trained on the household's voices would also complement speaker ID. Note: bundled openWakeWord models are CC BY-NC-SA.
10 STT vocabulary faster-whisper accepts hotwords / initial_prompt (verified in the installed 1.2.1). ✅ New opt-in stt.hotwords for names, rooms and device names.
11 Logging enqueue=True makes logging non-blocking; set diagnose=False in production "to avoid leaking sensitive data" (loguru API). ✅ Both applied (CORE-01).
12 LAN API Reject oversized bodies before parsing; plain HTTP exposes bearer tokens. ✅ Body-size guard (API-01). 📌 Scope the firewall rule to the private profile, or put Caddy/TLS in front (API-03).
13 Supply chain Pin majors of fast-moving deps; use a lock file. ✅ mcp>=1.2,<3. 📌 Add a lock (uv lock / pip-compile) and pin fastapi/starlette, faster-whisper, openwakeword.
14 CI Lint + tests on every push would have caught GUI-01 (ruff F821) and MCP-01. 📌 Add a GitLab CI job: ruff check src/javis --select F,E9,B + pytest.
15 Maintainability Split main_window.py per tab and inject config; extract built-in tool schemas and a single TTS pipeline from orchestrator.py (ARCH-01). 📌
16 Tool routing Drop generic verbs (möchte, wollen, what is) from the routing sets or match short words on word boundaries (AGT-09). 📌

New configuration options (all off by default)

stt:
  hotwords: "Javis, Esszimmer, Homey"   # bias Whisper towards these words
wake_word:
  vad_gate: 0.5                          # wake only when openWakeWord's VAD agrees