Add JARVIS_ROLE (full|browser|bot) via a run-if-role.sh supervisord guard so
one image serves three layouts. Make Chrome CDP bind configurable (CDP_BIND)
and publishable on the LAN (CDP_PUBLISH_BIND) so a bot on another PC can drive
this host's on-screen Chrome over the internal network (no auth, as requested).
GeoIP auto-detect is unavailable in the container, so '부산 날씨' failed
(no location). Extract the city from the utterance and pass it to getWeather.
Also report navigate by site name instead of the mid-load about:blank url.
The 7B narrated '구글 메인으로 돌아갑니다' without acting, so the screen stayed on
Naver. Split site intent into SEARCH vs NAV: nav words (돌아가/이동/열어/메인/go
back) now drive controlBrowser.navigate to the site homepage directly, search
words run controlBrowser.search — both deterministically, no LLM.
getWeather now returns only the Korean sentence (지금 <곳> 날씨는 <상태>, 기온 N도
(체감 M도)입니다) with no English/°C source. A deterministic weather path in the
engine returns it verbatim, bypassing the 7B which was rephrasing into multiple
sentences and leaking 'Celsius'.
qwen2.5:7b leaked Chinese/Cyrillic mid-reply despite the OUTPUT_LANGUAGE
lock, which was buried mid-prompt. Repeat the lock at the END of the system
prompt (recency) and ban specific foreign scripts explicitly.
getWeather returned a verbose multi-section English forecast that the 3B
re-synthesised into long, CJK/°F-leaking answers. Hand it a ready-to-speak
Korean one-liner (지금 <곳> 날씨는 <상태>, 기온 N도(체감 M도)입니다) and drop the
hourly/7-day firehose from the default voice reply.
Log the captured speech-clip duration (녹음/음성) separately from the Whisper
transcription time (STT처리) so it's clear whether a slow turn is the
listening/recording or the transcription, per the user's request.
- Site-specified search ("네이버에서 X 검색해줘") now runs controlBrowser.search
directly in the engine when broadcasting, instead of relying on the 3B model
to emit the tool call (it kept narrating "검색하겠습니다" without acting).
- Set OUTPUT_LANGUAGE=ko so replies are Korean-only — stops the small model
leaking CJK/Hanja and English fragments (每, 朗, "feels like") into weather
and other answers, and keeps them concise.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Greetings/small-talk routed no data tool yet still ran the episodic memory
enrichment (LLM keyword extract + diary/graph search, ~1s) every turn. Skip it
when the router picked no external-data tool — the always-injected warm profile
still personalises the reply. Also drop the voice silence-detection wait
800ms -> 600ms for snappier turn-taking. Warm "안녕" now lands well under the
3-4s target.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Root cause of "weather/search do nothing": the engine forced TEXT tool-calling
for all <=7B models, but qwen2.5:3b emits clean NATIVE tool calls and fails at
the text format — so it just narrated ("부산 날씨는 맑습니다") and never called
getWeather/webSearch/controlBrowser. Use native tool-calling for tool-capable
small families (qwen2.5/qwen3/llama3.x/mistral); native still auto-falls back
to text on HTTP 400, so non-tool models (gemma) are unaffected.
Also launch Chrome with --test-type (removes the "--no-sandbox unsupported
flag" infobar) and disable the Translate feature/popup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 3B model kept choosing webSearch over controlBrowser even when offered, so
'네이버에서 X 검색' still used the invisible web path. When broadcasting and the
user explicitly names a site, remove webSearch from the allow-list so the
on-screen browser is the only search route.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The small router reflexively routed every "search/open" intent to webSearch
and never surfaced controlBrowser, so "네이버에서 X 검색해줘" did nothing on the
broadcast. Union controlBrowser (+browseAndPlay) into the allow-list every
turn in screen-share mode (like setBroadcast), and steer the model in the
system prompt to prefer the on-screen browser over webSearch when available.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add a one-shot `search` action (site=naver/google/daum/youtube/bing) that
navigates the on-screen browser straight to the results page, so a small
model can satisfy "search X on Naver" in a single tool call instead of a
fragile navigate->type->enter chain.
- Sharpen the tool description to steer the router to controlBrowser (not
webSearch) for anything that should happen IN the visible browser.
- System prompt: answer in one short sentence (voice assistant) — also cuts
TTS time.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Install xdotool + wmctrl (late Docker layer, preserves the melo cache) so
the on-screen Chrome gets real X input (visible cursor, char-by-char typing)
instead of synthetic events; falls back to the Playwright API if absent.
- Fix active-tab detection (probe document.visibilityState instead of assuming
tab 0) so sequential ops target the right tab.
- Add back / forward / refresh; new/switch/close tabs via real keyboard
(Ctrl+T / Ctrl+<n> / Ctrl+W) when xdotool is present.
- Auto-dismiss native JS dialogs; closePopups clears blank/popup tabs.
- Report broadcast (Go-Live) state in status from the turn context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
supervisord hardcoded MELO_DEVICE=cpu, overriding the compose MELO_DEVICE=cuda
so MeloTTS stayed on CPU even after the GPU torch swap. Interpolate the
container env instead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a general browser-control tool (navigate to any site, list/open/close/
switch tabs, close popups, click, type, scroll, screenshot) for the Go-Live
Chrome, on top of the existing CDP + xdotool human-input layer (visible
cursor, char-by-char typing). Closes the gap where "open Naver" had no tool
and the model confabulated success. Also adds a system-prompt rule against
claiming actions no tool actually performed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CPU MeloTTS serialised under concurrent load (whisper STT + bot) and blew
voice-reply TTS to 7-8s. Install the Blackwell-verified cu128 torch in the
melo venv, select the GPU via MELO_DEVICE=cuda, and do a throwaway synth at
worker startup so the one-off CUDA kernel-init (~5s) doesn't land on the
user's first reply. Measured: ~0.3s/sentence on GPU vs ~1.2-2.6s on CPU.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- llm.py: log each Ollama call's caller + total/load/prompt/gen durations
so a slow voice turn is attributable to a specific internal call
(router/enrichment/digest/main); a RELOAD marker flags cold reloads.
- voice.ts: track in-flight Opus captures and abort them on session
destroy(); drop any utterance that finishes after the user left, so no
trailing post-leave VAD turns are reported.
- userbot.ts: show the speaker's Discord user ID on each transcript line
(answered and dropped) so it's clear whose audio produced the turn.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The startup warm-up loaded qwen at the default context, but the reply
engine chats at num_ctx=8192 — a different Ollama instance — so the first
real turn still cold-reloaded. Warm at OLLAMA_NUM_CTX via /api/chat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ollama keeps a separate loaded model instance per (model, num_ctx). The
main agentic chat used num_ctx=8192 while the router/enrichment/digest
passes used 4096, so every voice turn forced at least one cold reload
(~3.4s) when switching context sizes — the dominant per-turn latency
(measured: resident chat call 0.27s vs cold 3.4s).
Introduce a single OLLAMA_NUM_CTX (default 8192, env-tunable for tight
VRAM) used by call_llm_direct, chat_with_messages, call_llm_streaming and
the planner, collapsing a turn to one resident instance.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The first spoken turn paid a ~10s cold start because Whisper (default
"medium") and the Ollama chat model loaded lazily on the first request.
Warm them (and ping the TTS worker) in a background thread at startup so
the server accepts requests immediately while models load, and the first
real utterance is fast.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The transcript channel only showed STT and LLM seconds. Add wall-clock
start/end times and durations for listening, LLM and TTS so it's obvious
what takes long; STT surfaces as the gap between listening end and LLM start.
- bridge: emit llm_start_ms/llm_end_ms on meta and tts_*_ms on the end event
- bot: capture the listening window, assemble full timing after the stream,
and render a per-stage breakdown in the transcript message
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Proven approach: the conversation (hear+speak) runs on @discordjs/voice; the
Go-Live broadcast is a SEPARATE stream connection created on the SAME selfbot
session (exactly like a real Discord client), so ONE account hears, speaks, AND
broadcasts — no second login, no self_deaf, no voice conflict.
- voice.ts captures its own voice session_id (adapter wrap) and exposes
getSharedSession() {client, guildId, channelId, sessionId, botId}.
- broadcast.ts threads it into the StreamContext.
- selfbot.ts: when a shared session is present, build the Streamer on the
conversation client and create the stream on its session_id (no login/joinVoice/
humanPause); teardown only stops the stream (never leaves voice/destroys the
shared client). Falls back to the dedicated-account path otherwise.
Verified live: Go-Live connected in ~7s while the conversation voice stayed
ready, and the broadcast was visible in Discord — all on one account.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The transcript channel only showed successful transcripts, so dropped utterances
(the 47/50 misses) were invisible. Now every captured utterance is mirrored with
its outcome and per-stage timing:
- too-short blip (<300ms), VAD "음성 아님(VAD 차단)", "인식 실패", "답변 없음", or "ok"
- transcript + reply (or "(무응답)")
- ⏱️ stt/llm seconds
The bridge meta now carries note + stt_sec + think_sec; voice.ts fires onTurn for
every turn (not only non-empty transcripts) and for the too-short drop; userbot
formats the diagnostic line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Set DISCORD_TRANSCRIPT_CHANNEL_ID to a text channel and the userbot posts
"들은 말: <transcript> / 답변: <reply or (무응답)>" for every voice turn, so you can
verify what the bot understood even when it doesn't answer aloud.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Whisper 'small' frequently mis-transcribes Korean (esp. the wake word 자비스:
'아비스', '자 비싸'). STT is only ~0.1s warm so there is ample latency headroom;
'medium' is far more accurate and fits VRAM alongside qwen2.5:3b on the 8GB GPU.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Warm per-turn timing showed STT 0.1s, TTS ~1-3s, but the reply engine (LLM) was
8-17s — even a simple "고마워" took 16.7s — because it makes multiple model calls
per turn. Add a PLANNER_ENABLED env override (config.py) and default it to 0 in
the userbot compose so the pre-loop planner's extra LLM round-trip is dropped on
this latency-sensitive voice deployment. Also pins STT_LANGUAGE=ko in compose.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- transcribe() now passes language="ko" (STT_LANGUAGE env, default ko): skips
Whisper auto-detect, fixing occasional Korean->Chinese mis-detection and
shaving latency. LLM is already locked via OUTPUT_LANGUAGE=Korean; MeloTTS is
Korean-only — so STT/LLM/TTS are all Korean now.
- converse_stream logs "⏱️ turn stt=.. think(LLM)=.. tts=.. total=.." so the
~30s voice-reply latency can be attributed to the real bottleneck stage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Live test showed the bot needed ~30 tries to answer once: 15 of 18 captured
utterances were dropped as "segment dropped (confidence=...)" — but they were
real speech ("자비스 안녕" transcribed at avg_logprob-confidence 0.0-0.29), killed
by the min_confidence=0.3 floor. Short/quiet/accented Korean over a Discord mic
scores very low avg_logprob, so the confidence floor eats real speech.
Noise rejection is handled by the VAD pre-gate + no_speech_prob hard cutoff
(both kept). The avg_logprob floor is now OFF by default (STT_MIN_CONFIDENCE=0,
env-tunable), and the no_speech threshold is env-tunable too. Raise only if
hallucinations slip past the no_speech gate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Critical regression: in single-account userbot mode the broadcast auto-start
logged in a SECOND session of the conversation account and called joinVoice,
which the streaming lib always sends with self_deaf:true. Voice state is
per-account, so this deafened the CONVERSATION session too and the bot silently
stopped hearing the user (STT receive broke). The Go-Live cannot connect on a
shared account anyway.
start() now refuses early (no joinVoice, no deafen) when there is no dedicated
DISCORD_STREAM_TOKEN and no normal-bot token, protecting the conversation. A
dedicated stream account re-enables the broadcast.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Confirmed root cause of "broadcast doesn't appear in Discord" in userbot mode:
the conversation and the Go-Live broadcaster were the SAME Discord account on
two sessions. Discord allows one voice presence per account, so the broadcaster's
voice connection never connects (state voiceReady:false). Proven by isolating the
broadcaster: alone it connects ("Go-Live WebRTC connected"); alongside the
conversation it times out.
The broadcaster now logs in with DISCORD_STREAM_TOKEN when set (a second burner
account dedicated to streaming), falling back to DISCORD_SELFBOT_TOKEN (correct
for normal-bot mode). When userbot mode shares one account it warns loudly with
the fix. Documents the var in .env.example.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
isActive() was a local flag set at start() time, and start() fired playStream
without awaiting the actual stream connection, so 'broadcasting' was reported
even when nothing reached Discord (auto-start log + ffmpeg/NVENC running are not
proof of transmission).
start() now waits for the streaming library's real readiness signal (the stream
connection's WebRTC reaching "connected") before declaring live. On timeout it
logs a compact connection-state diagnostic, tears the local ffmpeg pipeline down
immediately, and returns an explicit failure. isActive() reports the real live
state. Timeout is config-driven (STREAM_READY_TIMEOUT_MS, default 25s). Adds a
test for the timeout/teardown path and updates the existing leak-teardown test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Makes the Go-Live auto-start verifiable from the container logs (the reviewer
flagged that a successful broadcast left no log record), alongside the existing
loud failure log.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address review of the noise/broadcast fixes:
- STT now refuses to run Whisper on non-speech. transcribe() runs the Silero
VAD (bundled with faster-whisper, no new dep) BEFORE the model, so noise or a
brief loud blip with no real speech never reaches STT and can't be
hallucinated into a transcript. The no_speech_prob/avg_logprob post-filter
stays as a second line of defence (a clap the VAD lets through is still killed
by Whisper's own no_speech_prob). VAD is env-tunable (VAD_THRESHOLD,
VAD_MIN_SPEECH_MS, VAD_ENABLED) and fail-open so a VAD error never swallows a
real utterance. Validated on real audio: synthesised Korean speech passes;
silence, a 50ms blip and white noise are rejected.
- Broadcast auto-start no longer blocks the voice join and no longer silently
swallows failures: wiring is synchronous, the Go-Live start runs in the
background with a bounded retry and a loud final-failure log.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The bridge transcribe path joined every Whisper segment unconditionally, so a
brief loud sound or background noise that momentarily opened the mic gate (no
real speech) still produced a transcript, and Whisper's noise hallucinations
("감사합니다", "MBC 뉴스", ...) made the bot reply to nothing.
Add bridge/stt_filter.py mirroring the desktop listener's _filter_noisy_segments
policy: a hard no_speech_prob cutoff (whisper_no_speech_threshold) plus an
avg_logprob confidence floor (whisper_min_confidence), both config-driven. Apply
it in transcribe() so only segments that look like human speech survive; a
noise-only turn yields an empty transcript and the existing empty-transcript
guard drops it with no reply. Add unit tests for the gate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "couple broadcast to voice" feature only wired auto-start into the
normal-bot join handler. Userbot mode (the only mode that can actually Go
Live) was added later with Go-Live deferred to a "stage 2" that never
landed, so the running deployment had no path to start a broadcast.
Extract the coupling into a shared bot/src/broadcast.ts (auto-start on
join, report live state to the brain each turn, voice toggle) and wire it
into both index.ts and userbot.ts so both modes behave identically. Add a
behavioural test for the auto-start + toggle contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The streaming splitter treated every "." as a sentence boundary, so the
operational reply "17.5°C" was read as "17." / "5°C" and "1.8 km/h" as
"1." / "8 km/h" - numbers spoken digit-by-digit plus extra TTS calls.
An ASCII terminator (. ! ?) now only ends a sentence when it is followed by
whitespace, a closing quote/bracket, or end of text. In-token dots (decimals
"17.5", versions "v2.0", hosts "example.com") are followed by a digit/letter,
so they no longer split. CJK fullwidth terminators stay unconditional since
those scripts use no trailing space. Language-agnostic, punctuation only.
- bridge: lookahead-gated boundary regex + finditer-based chunking
- tests: regression cases for decimals (17.5/1.8), versions, URLs, and an
integer that genuinely ends a sentence
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /converse turn synthesised the entire reply before any audio played, so
time-to-first-audio grew with reply length. Add a streaming /converse_stream
endpoint that emits the transcript/reply first, then one audio clip per
sentence as each finishes synthesising. The Discord voice layer enqueues each
clip on arrival via the existing FIFO playQueue, so the first sentence starts
speaking while the rest are still being synthesised.
STT and the reply engine still run to completion before the first clip; only
TTS is pipelined. The non-streaming /converse and /text endpoints are
unchanged.
- bridge: language-agnostic sentence splitter (bridge/text_utils.py) + NDJSON
streaming route
- bot: ndjson() reader + converseStream() client; voice.ts plays clips
progressively
- tests: splitter unit tests + bot ndjson/converseStream tests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Empirical A/B/C measurement against the live RTX 5050 Ollama stack
(qwen2.5:3b + nomic-embed-text) showed keep_alive=0 unloads the embed
model ~2s after every call, so each turn after a brief idle gap pays a
cold reload. VRAM is not the constraint (~4.4-4.7 GB free with both
models resident) and keep_alive=0 never evicted the chat model, so CPU
embedding (num_gpu=0) gave no benefit. A short positive keep_alive is
the fastest of the three: it keeps the ~0.3 GB embed model resident
across consecutive turns at negligible VRAM cost.
Add tests/test_embeddings.py covering the warm-across-turns behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "디스코드 토큰은 마지막에" section still told users to fill DISCORD_BOT_TOKEN
and call the `/자비스` slash command, but the default mode is the userbot
(selfbot) — a normal bot account cannot Go Live. Rewrite the setup to lead with
DISCORD_SELFBOT_TOKEN + DISCORD_VOICE_CHANNEL_ID and the text-command control
(`!자비스 join`/`leave`), with the burner-account ToS warning, and keep the
normal-bot path documented as the optional legacy alternative.
The bot, bridge and melo worker boot together, but the MeloTTS model takes
tens of seconds to load. If the bot logged in and auto-joined the voice channel
before the voice was warm, the first reply synthesised to nothing and was
silently dropped.
- bridge /health now reports `tts_ready`. For MeloTTS this pings the worker,
which only binds its HTTP port AFTER the model is loaded (main() warms before
serve_forever()), so a successful ping is a precise "voice is warm" signal.
- The bot polls /health and waits for `tts_ready` before logging in. It does
not wait on brain_ready (the reply engine / Whisper load lazily on the first
turn — a slow first turn is fine, a silent one is the bug). After a 180s cap
it proceeds anyway so a TTS load failure degrades to text-only.
Live-verified: startup logs show "⏳ MeloTTS 준비 대기 중" then
"✓ 음성(MeloTTS) 준비 완료 — 로그인 진행" then "✓ 유저봇 로그인", in that order.
Open-Meteo's geocoder only matches Latin/English spellings, so a Korean city
name like "서울" returns zero results even though the place exists. With
OUTPUT_LANGUAGE locked to Korean the tool-calling model naturally fills
`location` with the Korean name, which dead-ended on every weather request
("could not find location").
When the first geocode is empty and the name is non-ASCII, ask the warm small
model for the common English exonym ("서울" -> "Seoul") and retry once. ASCII
names skip the round-trip entirely.
Live-verified: "서울 날씨 알려줘" now returns real Seoul weather. Tests cover the
romanise-and-retry path and the ASCII short-circuit.
The config template never set intent_judge_model, so it fell back to the code
default gemma4:e2b. That model is not pulled by this stack (Ollama only has
qwen2.5:3b, qwen3:8b, nomic-embed-text), so every auxiliary small-model call —
intent judge, tool router, weather place extraction, query decomposition —
targeted a non-existent model, silently failed, and fell open. This crippled
tool routing and argument extraction on the 3B brain.
Render intent_judge_model from a new OLLAMA_INTENT_MODEL env var that defaults
to OLLAMA_CHAT_MODEL, so the auxiliary calls reuse the already-warm chat model
(one resident model, no extra load). tool_router_model="" then resolves through
the chain to the same model.
Verified: rendered jarvis.json now has intent_judge_model=qwen2.5:3b, and the
weather place extractor returns "서울" / "Tokyo" (it returned None for
everything while pointed at the missing gemma4:e2b).
qwen2.5:3b emits tool calls in text shapes the parser dropped, breaking
two reviewer-reported behaviours:
- `getWeather: {"location": "Seoul"}` (a JSON object after the colon) was
dumped wholesale into {"query": "{...}"}, so `location` never reached the
tool. getWeather then ran with empty args, returned the auto-detected
location's weather, the model noticed the mismatch and retried — looping up
to 8 times before giving up with an English error. Now the JSON object after
the colon is parsed directly as the argument dict.
- `call_stop: {"id":..., "function": {"name": "setBroadcast",
"arguments": "{\"action\": \"stop\"}"}}` — a single tool_call object without
the `tool_calls: [...]` array wrapper, behind a `call_xxx:` label — matched
no form, so the raw JSON leaked to the user AND setBroadcast never ran
("방송 꺼줘" did nothing). Now name + arguments are pulled from the embedded
`function` object when the name is in the allow-list.
Field-captured from the live qwen2.5:3b brain (2026-06-12). Tests cover both
shapes, non-ASCII args, dict/string arguments, and unknown-tool rejection.
Harden the reply-language lock so qwen2.5:3b reliably stays in the locked
language instead of leaking the query language back in:
- reply_language_directive(): single resolver with clear precedence —
explicit OUTPUT_LANGUAGE lock wins over the Piper/Chatterbox English-only
fallback (this deployment's actual TTS is Korean MeloTTS, so the legacy
English lock was both wrong and contradicting the Korean lock).
- Stronger, override-explicit directive wording, inserted near the FRONT of
the system prompt so a small model gives it primacy over the persona.
- build_system_prompt(output_language=...): rewrite the persona's "in the
user's language" clause to the locked language so the persona stops
fighting the lock.
- docs/llm_contexts.md: document the resolver, precedence, and placement.
Live-verified on the running brain (qwen2.5:3b): Korean voice-style input
and a cold English query both return fully Korean replies with no CJK/Hanja
leak. Tests cover unset/set/agnostic/whitespace + precedence + persona rewrite.
Add an optional OUTPUT_LANGUAGE env var that forces every reply into a
single language. When set, output_language_directive() injects a "respond
only in <language>" instruction (also forbidding other scripts) into the
chat loop's system prompt, next to the existing TTS English-only lock.
Empty (default) keeps the multilingual "reply in the user's language"
behaviour, so upstream is unaffected.
For the Korean-only deployment this also suppresses the occasional trailing
CJK/Hanja fragment qwen2.5:3b leaks on free-form chit-chat.
- system_prompt.py: language-agnostic output_language_directive() helper
- engine.py: read OUTPUT_LANGUAGE, append directive in _build_initial_system_message
- docker-compose.yml + .env.example: document/pass the new var
- docs/llm_contexts.md: note the new gating on the main reply context
- tests: cover unset/set/agnostic/whitespace cases
Replace the blunt global OLLAMA_KEEP_ALIVE=-1 (which kept every model,
including nomic-embed, resident in VRAM forever) with per-request residency:
- llm.py: all three /api/chat payloads send keep_alive=30m so the actively
used chat model stays resident and voice turns never pay a cold reload.
- embeddings.py: /api/embeddings sends keep_alive=0 so nomic-embed unloads
right after each call instead of squatting in VRAM next to the chat model.
- docker-compose.yml: drop the global OLLAMA_KEEP_ALIVE=-1; document the
per-request scheme on the ollama service.
Switch the default chat model qwen3:8b -> qwen2.5:3b. Verified live on the
RTX 5050 (8GB):
- ollama ps: qwen2.5:3b 2.4GB, 100% GPU (8B was 92% GPU / 8% CPU), UNTIL ~30m
(the 30m pin, not "Forever"); nomic-embed absent after several enriched turns.
- nvidia-smi: ~3.2GB VRAM used total (qwen 2.4GB + whisper 0.7GB) vs ~6.6GB.
- Korean /text turns: warm 1.7-4s (cold first load ~52s), vs ~5-7s on 8B;
time/weather/places tool calls fire and reply in Korean.
Known limitation: qwen2.5:3b can occasionally leak a trailing CJK phrase on
free-form chit-chat (factual/tool replies stay clean).