34 KiB
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
- Baseline tooling:
ruff check --select F,E9,B(37 findings),compileall(clean),pytest(not installed in.venv, so no tests ran). - Line-by-line review of every subsystem (core, audio, STT, TTS/voices, LLM, MCP, API, agent, GUI).
- 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.runheadless). Blocking work (STT, TTS, speaker ID, PCM writes) is pushed to threads withasyncio.to_thread. - Turn serialisation.
Orchestrator._turn_lockserialises wake-word, PTT, typed, remote (slave) turns, enrollment and timer announcements. - Middleware. TanStack-style hooks (
on_before_llmpiped,on_before_toolfirst-win, the rest sequential).PrivilegeMiddlewareis the per-speaker ACL. - Tool routing. Keyword heuristics pick a subset of MCP servers per turn; a
javis__load_toolsmeta-tool lets the model pull in un-routed servers. - GUI. One
_MainWindowclass (defined insiderun_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.pyL12:getattr(tool, "inputSchema", None)mcp_client/host.pyL461:getattr(result, "isError", False)mcp_client/host.pyL261: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:
- Every MCP tool is advertised to the LLM as
{"type":"object","properties":{}}— the model has to guess every argument name. isErrorresults are never prefixed withERROR:→ 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()andrun_start()run before thetry/finallythat resets state. If any raises, the state stays THINKING and the cue task loopswhile 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_turnleaked 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 insiderun_gui(), capturingconfigfrom the closure; it cannot be imported or tested in isolation. Recommended split: one module per tab + aMainWindowclass takingconfigexplicitly.orchestrator.py(~3.3 k lines) embeds ~300 lines of JSON tool schemas;_speakand_speak_streamduplicate the synth→play pipeline. Recommended:agent/builtin_tools.pyand atts_pipeline.pywith 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