Files
javis_bot/src/jarvis/config.py
javis-bot 5ee47827f3 perf: cap chat output tokens via ollama_num_predict to bound reply latency
Spoken (TTS) replies are 1-2 sentences, so an unbounded num_predict only
exposes the worst case where the chat model rambles or loops. Add an
ollama_num_predict config (default 512, 0 disables) wired into the reply
loop's chat call on both the native- and text-tool paths. The 512-token
headroom stays well above this app's short tool-call JSON, so capping never
truncates a tool call. This keeps the user's quality model instead of
downgrading it. Configurable in the container via OLLAMA_NUM_PREDICT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 15:33:45 +09:00

914 lines
39 KiB
Python

import os
import sys
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional
from dotenv import load_dotenv
# ============================================================================
# SUPPORTED CHAT MODELS - Single Source of Truth
# ============================================================================
# This is the authoritative list of officially supported chat models.
# Other modules should import from here rather than defining their own lists.
SUPPORTED_CHAT_MODELS: Dict[str, Dict[str, str]] = {
"gemma4:e2b": {
"name": "Gemma 4 E2B (Default)",
"description": "Fast, multimodal, effective 2B — a little dumb, occasionally fumbles tool calls; ~7.2GB download",
"size": "~7.2GB",
"vram": "8GB+",
},
"gemma4:e4b": {
"name": "Gemma 4 E4B (Recommended)",
"description": "Smarter tool use and reasoning, multimodal, effective 4B — ~9.6GB download",
"size": "~9.6GB",
"vram": "16GB+",
},
"gpt-oss:20b": {
"name": "GPT-OSS 20B (High-end)",
"description": "Best performance, ~12GB download",
"size": "~12GB",
"vram": "24GB+",
},
}
# The default chat model (first in the supported list)
DEFAULT_CHAT_MODEL = "gemma4:e2b"
def get_supported_model_ids() -> set[str]:
"""Get set of supported model IDs for quick lookup."""
return set(SUPPORTED_CHAT_MODELS.keys())
def _default_dictation_hotkey() -> str:
"""Return the platform-appropriate default dictation hotkey.
Aligned with WisprFlow defaults:
- Windows: Ctrl+Win (pynput maps Win to ``cmd``)
- macOS: Fn is not detectable by pynput, so use Ctrl+Option (WisprFlow
fallback when Fn is unavailable)
- Linux: Ctrl+Alt (mirrors macOS fallback)
"""
if sys.platform == "win32":
return "ctrl+cmd"
elif sys.platform == "darwin":
return "ctrl+alt"
else:
return "ctrl+alt"
def _default_db_path() -> str:
base = Path.home() / ".local" / "share" / "jarvis"
base.mkdir(parents=True, exist_ok=True)
return str(base / "jarvis.db")
@dataclass(frozen=True)
class Settings:
# Database & Storage
db_path: str
sqlite_vss_path: str | None
# LLM & AI Models
ollama_base_url: str
ollama_embed_model: str
ollama_chat_model: str
llm_chat_timeout_sec: float
llm_tools_timeout_sec: float
# Tight deadline for the cheap distil passes used by memory_digest and
# tool_result_digest. Separate from `llm_tools_timeout_sec` because
# those paths run a small classification-shaped LLM call, not a
# long-running tool — a 5-minute ceiling there would stall replies.
llm_digest_timeout_sec: float
llm_embedding_timeout_sec: float
llm_profile_select_timeout_sec: float
# Upper bound on tokens the chat model may generate per reply turn. Spoken
# (TTS) answers are 1-2 sentences, so a cap bounds the worst-case latency of
# a model that occasionally rambles or loops without changing normal answers.
# The headroom (default 512) sits well above this app's short tool-call JSON,
# so capping never truncates a tool call. 0 (or negative) disables the cap.
ollama_num_predict: int
# Profiles & Behavior
active_profiles: list[str]
use_stdin: bool
voice_debug: bool
# Screen Capture
allowlist_bundles: list[str]
# Text-to-Speech
tts_enabled: bool
tts_engine: str # "piper" (default) or "chatterbox"
tts_voice: str | None
tts_rate: int | None # Words per minute (WPM), 200=normal
tts_chatterbox_device: str # "cuda", "auto", or "cpu" for Chatterbox
tts_chatterbox_audio_prompt: str | None # Path to audio file for voice cloning with Chatterbox
tts_chatterbox_exaggeration: float # Emotion exaggeration control (0.0-1.0+)
tts_chatterbox_cfg_weight: float # CFG weight for quality/speed trade-off
# Piper TTS
tts_piper_model_path: str | None # Path to .onnx voice model
tts_piper_speaker: int | None # Speaker ID for multi-speaker models
tts_piper_length_scale: float # Speed: <1.0 faster, >1.0 slower
tts_piper_noise_scale: float # Audio variation
tts_piper_noise_w: float # Phoneme width variation
tts_piper_sentence_silence: float # Post-sentence silence in seconds
# Voice Input & Audio
voice_device: str | None
sample_rate: int
voice_min_energy: float
# Voice Collection & Timing
voice_block_seconds: float
voice_collect_seconds: float
voice_max_collect_seconds: float
# Wake Word Detection
wake_word: str
wake_aliases: list[str]
wake_fuzzy_ratio: float
# Whisper Speech Recognition
whisper_model: str
whisper_backend: str # "auto", "mlx", or "faster-whisper"
whisper_device: str # "cuda", "auto", or "cpu" (only for faster-whisper)
whisper_compute_type: str
whisper_vad: bool
whisper_min_confidence: float
whisper_no_speech_threshold: float
whisper_min_audio_duration: float
whisper_min_word_length: int
# Voice Activity Detection (VAD)
vad_enabled: bool
vad_aggressiveness: int
vad_frame_ms: int
vad_pre_roll_ms: int
endpoint_silence_ms: int
max_utterance_ms: int
tts_max_utterance_ms: int
# UI/UX Features
tune_enabled: bool
hot_window_enabled: bool
hot_window_seconds: float
# Echo Detection
echo_energy_threshold: float
echo_tolerance: float
# Intent Judge (LLM-based intent classification)
# Always used when available, falls back to simple wake word detection
intent_judge_model: str
intent_judge_timeout_sec: float
# Transcript Buffer - ambient speech context for intent judge
transcript_buffer_duration_sec: float
# Memory & Dialogue
# Drives both the short-term memory window and forced diary update interval
dialogue_memory_timeout: float
memory_enrichment_max_results: int
memory_enrichment_source: str # "all", "diary", or "graph"
# Tool-call + tool-result messages from prior replies in the hot window
# are re-injected into the next turn so follow-ups can reuse them instead
# of re-fetching. These knobs cap how many prior tool turns survive and
# how much of each tool payload is retained (the fence markers of
# UNTRUSTED WEB EXTRACT blocks are preserved on truncation).
tool_carryover_max_turns: int
tool_carryover_per_entry_chars: int
# Distil diary + graph into a short relevance-filtered note via a cheap
# LLM pass before injecting into the reply system prompt. When None
# (the default), it auto-enables for SMALL models (≤7B) and stays off
# for larger models that can handle raw dumps. Set explicitly to force.
memory_digest_enabled: Optional[bool]
# Distil raw tool-result payloads (e.g. webSearch extracts) into a
# short, attributed fact note via a cheap LLM pass before appending
# them as tool-role messages. When None (the default), it auto-enables
# for SMALL models (≤7B) and stays off for larger models that ground
# on the raw payload reliably. Set explicitly to force on/off.
tool_result_digest_enabled: Optional[bool]
# Agentic Loop
agentic_max_turns: int
tool_selection_strategy: str # "all", "keyword", "embedding", or "llm"
# When `tool_selection_strategy == "llm"`, this model does the routing.
# Empty string means "reuse `ollama_chat_model`" (the default).
tool_router_model: str
# Optional override for the post-turn evaluator LLM. Empty string means
# "fall back to intent_judge_model, then ollama_chat_model" (the default).
evaluator_model: str
# None = auto (on for SMALL models, off for LARGE). Explicit true/false forces.
evaluator_enabled: Optional[bool]
# Upper bound on toolSearchTool invocations per reply turn. The cap
# prevents a small model from churning through the escape hatch forever
# when no tool really fits.
tool_search_max_calls: int
# Upper bound on evaluator-driven nudges per reply. Each time the
# evaluator says "continue" with a nudge, the nudge is injected into
# the next turn's system message. This cap stops nudge ping-pong when
# the model keeps producing prose despite the nudge.
evaluator_nudge_max: int
# Optional override for the pre-loop task-list planner model. Empty
# string means "fall back to tool_router_model → intent_judge_model →
# ollama_chat_model" (the default). The planner is a small
# classification-shaped pass so it rides the same small-model chain
# as the router and the evaluator.
planner_model: str
# Whether the pre-loop planner is enabled. True = planner always runs;
# False = planner never runs (legacy behaviour, with the
# compound_query fallback still active). Default True — the planner
# fails open to an empty plan so the cost of a miss is one cheap LLM
# round-trip, and the upside is multi-step queries actually complete.
planner_enabled: bool
# Timeout for the planner LLM call. Short because the planner is on
# the critical path — a long timeout would dominate first-token
# latency for every query. Planner fails open on timeout.
planner_timeout_sec: float
# Location Services
location_enabled: bool
location_cache_minutes: int
location_ip_address: str | None
location_auto_detect: bool
location_cgnat_resolve_public_ip: bool
# Web Search
web_search_enabled: bool
# Optional Brave Search API key. When set, Brave is used as the primary
# fallback when DuckDuckGo is rate-limited or returns no usable content.
# Empty string means "not configured" — the tool then falls through to
# the always-on Wikipedia fallback. Free tier is 2,000 queries/month.
brave_search_api_key: str
# Real-time info routing (mirrors the bot's STREAM_BROWSER, read from env).
# True -> browser tools drive the on-screen Chrome (visible on the broadcast).
# False -> geminiSearch uses the Gemini API (gemini_api_key / gemini_model).
stream_browser: bool
# "oauth" -> geminiSearch shells out to the Gemini CLI using the user's
# Google account login (no API key); built-in web-search grounding.
# "apikey" -> legacy REST path using gemini_api_key / gemini_model.
gemini_auth: str
gemini_api_key: str
gemini_model: str
# Zero-config Wikipedia fallback toggle. When True (default), the tool
# queries Wikipedia's REST summary API as a last resort before giving up
# with the honest "blocked" envelope. Privacy-light (public API, no key,
# no account) and language-aware via the Whisper-detected utterance
# language.
wikipedia_fallback_enabled: bool
# Dictation (hold-to-dictate)
dictation_enabled: bool
dictation_hotkey: str
dictation_filler_removal: bool
dictation_custom_dictionary: list
# MCP Integration
mcps: Dict[str, Any]
def default_config_path() -> Path:
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
return Path(xdg) / "jarvis" / "config.json"
return Path.home() / ".config" / "jarvis" / "config.json"
def _load_json(path: Path) -> Dict[str, Any]:
try:
if path.exists():
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def _save_json(path: Path, data: Dict[str, Any]) -> bool:
"""Save config data to JSON file. Returns True on success."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
return True
except Exception:
return False
def _migrate_config(cfg_path: Path, cfg_json: Dict[str, Any]) -> Dict[str, Any]:
"""
Apply config migrations for version upgrades.
Returns the (possibly modified) config dict.
"""
modified = False
# Get current migration version (0 if not set = pre-migration config)
migration_version = cfg_json.get("_config_version", 0)
# Migration v1: tts_engine "system" -> "piper"
# Piper is now the default TTS with auto-download support.
if migration_version < 1:
if cfg_json.get("tts_engine") == "system":
cfg_json["tts_engine"] = "piper"
print("📢 Upgraded TTS engine: system → piper (neural voice with auto-download)", flush=True)
print(" To revert: set \"tts_engine\": \"system\" in config.json", flush=True)
cfg_json["_config_version"] = 1
modified = True
# Save migrated config
if modified:
if _save_json(cfg_path, cfg_json):
pass # Silent success
else:
print(" ⚠️ Could not save config migration (using new settings in memory).", flush=True)
return cfg_json
def load_config() -> Dict[str, Any]:
"""
Load and return the merged configuration dictionary.
Returns defaults merged with any values from the config file.
Unlike load_settings(), this returns the raw dict instead of a Settings object.
"""
cfg_path_env = os.environ.get("JARVIS_CONFIG_PATH")
cfg_path = Path(cfg_path_env).expanduser() if cfg_path_env else default_config_path()
cfg_json = _load_json(cfg_path)
# Apply config migrations for version upgrades
if cfg_json:
cfg_json = _migrate_config(cfg_path, cfg_json)
defaults = get_default_config()
return {**defaults, **cfg_json}
def _ensure_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value]
if isinstance(value, str):
return [v.strip() for v in value.split(",") if v.strip()]
return [str(value)]
def _ensure_dict(value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return value
# Accept list of pairs like [{"name":..., ...}] and convert to dict by name if present
try:
if isinstance(value, list):
out: Dict[str, Any] = {}
for item in value:
if isinstance(item, dict):
key = str(item.get("name")) if item.get("name") is not None else None
if key:
out[key] = {k: v for k, v in item.items() if k != "name"}
if out:
return out
except Exception:
pass
return {}
def get_default_config() -> Dict[str, Any]:
"""Returns the default configuration values."""
return {
# Database & Storage
"db_path": _default_db_path(),
"sqlite_vss_path": None,
# LLM & AI Models
"ollama_base_url": "http://127.0.0.1:11434",
"ollama_embed_model": "nomic-embed-text",
"ollama_chat_model": DEFAULT_CHAT_MODEL,
"llm_chat_timeout_sec": 180.0,
"llm_tools_timeout_sec": 300.0,
# Cheap distil passes should fail fast — a hung digest call would
# block the reply loop per tool call, amplified by agentic turns.
"llm_digest_timeout_sec": 8.0,
"llm_embedding_timeout_sec": 60.0,
"llm_profile_select_timeout_sec": 30.0,
# Cap on chat-model output tokens per turn (worst-case latency guard).
# 512 is safe headroom above short TTS answers and tool-call JSON; 0 off.
"ollama_num_predict": 512,
# Profiles & Behavior
"active_profiles": ["developer", "business", "life"],
"use_stdin": False,
# Screen Capture
"allowlist_bundles": [
"com.apple.Terminal",
"com.googlecode.iterm2",
"com.microsoft.VSCode",
"com.jetbrains.intellij",
],
# Text-to-Speech
"tts_enabled": True,
"tts_engine": "piper", # "piper" (default) or "chatterbox"
"tts_voice": None,
"tts_rate": 200, # Words per minute (WPM), 200=normal
"tts_chatterbox_device": "cuda", # "cuda" (recommended), "auto", or "cpu"
"tts_chatterbox_audio_prompt": None, # Path to audio file for voice cloning
"tts_chatterbox_exaggeration": 0.5, # Emotion exaggeration (0.0-1.0+)
"tts_chatterbox_cfg_weight": 0.5, # CFG weight for quality/speed trade-off
# Piper TTS
"tts_piper_model_path": None, # Path to .onnx voice model
"tts_piper_speaker": None, # Speaker ID for multi-speaker models
"tts_piper_length_scale": 0.65, # Speed: <1.0 faster, >1.0 slower (0.65 = ~30% faster)
"tts_piper_noise_scale": 0.8, # Audio variation (higher = more expressive)
"tts_piper_noise_w": 1.0, # Phoneme width variation (higher = more lively)
"tts_piper_sentence_silence": 0.2, # Post-sentence silence in seconds
# Voice Input & Audio
"voice_device": None,
"sample_rate": 16000,
"voice_min_energy": 0.02,
# Voice Collection & Timing
"voice_block_seconds": 4.0,
"voice_collect_seconds": 4.5,
"voice_max_collect_seconds": 180.0,
# Wake Word Detection
"wake_word": "jarvis",
"wake_aliases": ["joris", "charis", "chavis", "jar is", "jaivis", "jervis", "jarvus", "jarviz", "javis", "jairus", "jarryst", "chyrus"],
"wake_fuzzy_ratio": 0.78,
# Whisper Speech Recognition
"whisper_model": "medium",
"whisper_backend": "auto", # "auto" (MLX on Apple Silicon, else faster-whisper), "mlx", or "faster-whisper"
"whisper_device": "auto", # "cuda" (recommended if available), "auto", or "cpu" (only for faster-whisper)
"whisper_compute_type": "int8",
"whisper_vad": True,
"whisper_min_confidence": 0.3, # Filter low-confidence segments (hallucinations)
"whisper_no_speech_threshold": 0.5, # Hard cutoff: reject segments where no_speech_prob >= this
"whisper_min_audio_duration": 0.15,
"whisper_min_word_length": 1,
# Voice Activity Detection (VAD)
"vad_enabled": True,
"vad_aggressiveness": 2,
"vad_frame_ms": 20,
"vad_pre_roll_ms": 240,
"endpoint_silence_ms": 800,
"max_utterance_ms": 12000,
"tts_max_utterance_ms": 3000, # Shorter timeout during TTS for quick stop detection
# UI/UX Features
"tune_enabled": True,
"hot_window_enabled": True,
"hot_window_seconds": 3.0,
"echo_energy_threshold": 2.0,
"echo_tolerance": 0.3, # Time tolerance for echo detection timing
# Audio Wake Word Detection
# Intent Judge (LLM-based intent classification)
# Always used when available, falls back to simple wake word detection
"llm_thinking_enabled": False, # Enable thinking/reasoning mode for chat (slower but may improve quality)
"intent_judge_model": "gemma4:e2b", # Model for intent judging (needs reasoning ability)
"intent_judge_timeout_sec": 15.0, # Max time to wait for intent judge response
"intent_judge_thinking_enabled": False, # Enable thinking for intent judge (adds latency to wake detection)
# Transcript Buffer - used for both retention and context passed to intent judge
# 120s (2 min) provides enough ambient speech context for intent judging
# in group conversations. Separate from dialogue memory.
"transcript_buffer_duration_sec": 120.0,
# Memory & Dialogue
# dialogue_memory_timeout drives the short-term memory window AND the forced
# diary update interval. After a diary update, enrichment retrieves older context.
"dialogue_memory_timeout": 300.0,
"memory_enrichment_max_results": 3,
"memory_enrichment_source": "all", # "all", "diary", or "graph"
# Tool carryover: cap re-injected prior tool turns + chars per entry.
"tool_carryover_max_turns": 2,
"tool_carryover_per_entry_chars": 1200,
# None = auto (on for small models ≤7B, off for large). Set true/false to force.
"memory_digest_enabled": None,
# Distil raw tool results (e.g. webSearch extracts) into a short
# attributed fact note for small models. Defaults to off: the extra
# None = auto (on for small models ≤7B, off for large). Set true/false to force.
# Auto-on for small models mitigates fetch_web_page's 50k-char payloads
# blowing the 8192 num_ctx window before the main model sees them.
"tool_result_digest_enabled": None,
# Agentic Loop
"agentic_max_turns": 8,
"tool_selection_strategy": "llm",
# Empty string = reuse intent_judge_model (small, fast, already warm
# for wake-word paths), falling back to ollama_chat_model only if the
# judge model isn't set. Override to decouple routing from both —
# useful when you want routing on a dedicated smaller model.
"tool_router_model": "",
# Empty string = reuse intent_judge_model, falling through to
# ollama_chat_model only if the judge isn't set. Override to pin the
# evaluator to a dedicated small/fast model.
"evaluator_model": "",
# None = auto (on for small models, off for large). Set true/false to force.
"evaluator_enabled": None,
# Cap the number of toolSearchTool invocations per reply.
"tool_search_max_calls": 3,
# Cap the number of evaluator-driven nudges per reply.
"evaluator_nudge_max": 2,
# Task-list planner (see src/jarvis/reply/planner.spec.md). Empty
# model string = reuse tool_router_model → intent_judge_model →
# ollama_chat_model.
"planner_model": "",
"planner_enabled": True,
"planner_timeout_sec": 6.0,
# Stop Commands
"stop_commands": ["stop", "quiet", "shush", "silence", "enough", "shut up"],
"stop_command_fuzzy_ratio": 0.8,
# Location Services
"location_enabled": True,
"location_cache_minutes": 60,
"location_ip_address": None,
"location_auto_detect": True,
# When behind CGNAT (100.64.0.0/10), attempt a privacy-light external DNS query to discover true public IP.
# Uses a single OpenDNS resolver lookup of myip.opendns.com over DNS (no HTTP services). Disable to avoid any external request.
"location_cgnat_resolve_public_ip": True,
# Web Search
"web_search_enabled": True,
"brave_search_api_key": "",
"wikipedia_fallback_enabled": True,
# Dictation (hold-to-dictate, WisprFlow-like)
"dictation_enabled": True,
"dictation_hotkey": _default_dictation_hotkey(),
"dictation_filler_removal": False,
"dictation_thinking_enabled": False, # Enable thinking for dictation filler removal (adds latency)
"dictation_custom_dictionary": [],
# MCP Integration (external servers Jarvis can use). No defaults.
"mcps": {},
}
def export_example_config(include_db_path: bool = False) -> Dict[str, Any]:
"""Returns example config suitable for JSON export (with adjusted db_path)."""
config = get_default_config().copy()
if not include_db_path:
# Use a user-friendly path for examples
config["db_path"] = "~/.local/share/jarvis/jarvis.db"
return config
def load_settings() -> Settings:
# Load environment for debug toggles and optional config file path only
load_dotenv(override=False)
# Resolve config path
cfg_path_env = os.environ.get("JARVIS_CONFIG_PATH")
cfg_path = Path(cfg_path_env).expanduser() if cfg_path_env else default_config_path()
cfg_dir = cfg_path.parent
try:
cfg_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
# Load JSON configuration (non-debug settings)
cfg_json = _load_json(cfg_path)
# Apply config migrations for version upgrades
if cfg_json:
cfg_json = _migrate_config(cfg_path, cfg_json)
# Get defaults and merge with JSON (JSON wins)
defaults = get_default_config()
merged: Dict[str, Any] = {**defaults, **cfg_json}
# Build Settings. Some fields support env var overrides.
# Env overrides: JARVIS_VOICE_DEBUG, JARVIS_WHISPER_BACKEND
voice_debug = os.environ.get("JARVIS_VOICE_DEBUG", "0") == "1"
# Real-time info mode + Gemini account (shared with the bot's .env).
stream_browser = os.environ.get("STREAM_BROWSER", "true").strip().lower() not in ("0", "false", "no")
gemini_auth = os.environ.get("GEMINI_AUTH", "oauth").strip().lower() or "oauth"
gemini_api_key = os.environ.get("GEMINI_API_KEY", "").strip()
gemini_model = os.environ.get("GEMINI_MODEL", "").strip() or "gemini-2.0-flash"
# Normalize/convert fields
db_path = str(merged.get("db_path") or _default_db_path())
sqlite_vss_path = merged.get("sqlite_vss_path")
allowlist_bundles = _ensure_list(merged.get("allowlist_bundles"))
ollama_base_url = str(merged.get("ollama_base_url"))
ollama_embed_model = str(merged.get("ollama_embed_model"))
ollama_chat_model = str(merged.get("ollama_chat_model"))
use_stdin = bool(merged.get("use_stdin", False))
active_profiles = _ensure_list(merged.get("active_profiles"))
tts_enabled = bool(merged.get("tts_enabled", True))
tts_engine = str(merged.get("tts_engine", "piper")).lower()
# "edge" (Microsoft Edge TTS) is the containerized bridge's Korean voice;
# "melo" is the legacy warm-worker voice. Both are multilingual, so they must
# be preserved here — coercing them to "piper" would mislabel the engine as
# English-only in reply_language_directive().
if tts_engine not in ("piper", "chatterbox", "edge", "melo"):
tts_engine = "piper" # Default to piper if invalid value
tts_voice_val = merged.get("tts_voice")
tts_voice = None if tts_voice_val in (None, "", "null") else str(tts_voice_val)
tts_rate_val = merged.get("tts_rate")
try:
tts_rate = None if tts_rate_val in (None, "", "null") else int(tts_rate_val)
except Exception:
tts_rate = None
tts_chatterbox_device = str(merged.get("tts_chatterbox_device", "cuda")).lower()
if tts_chatterbox_device not in ("cuda", "auto", "cpu"):
tts_chatterbox_device = "cuda" # Default to cuda if invalid value
tts_chatterbox_audio_prompt_val = merged.get("tts_chatterbox_audio_prompt")
tts_chatterbox_audio_prompt = None if tts_chatterbox_audio_prompt_val in (None, "", "null") else str(tts_chatterbox_audio_prompt_val)
tts_chatterbox_exaggeration = float(merged.get("tts_chatterbox_exaggeration", 0.5))
tts_chatterbox_cfg_weight = float(merged.get("tts_chatterbox_cfg_weight", 0.5))
# Piper TTS settings
tts_piper_model_path_val = merged.get("tts_piper_model_path")
tts_piper_model_path = None if tts_piper_model_path_val in (None, "", "null") else str(tts_piper_model_path_val)
tts_piper_speaker_val = merged.get("tts_piper_speaker")
try:
tts_piper_speaker = None if tts_piper_speaker_val in (None, "", "null") else int(tts_piper_speaker_val)
except Exception:
tts_piper_speaker = None
tts_piper_length_scale = float(merged.get("tts_piper_length_scale", 0.65))
tts_piper_noise_scale = float(merged.get("tts_piper_noise_scale", 0.8))
tts_piper_noise_w = float(merged.get("tts_piper_noise_w", 1.0))
tts_piper_sentence_silence = float(merged.get("tts_piper_sentence_silence", 0.2))
voice_device_val = merged.get("voice_device")
voice_device = None if voice_device_val in (None, "", "default", "system") else str(voice_device_val)
voice_block_seconds = float(merged.get("voice_block_seconds", 4.0))
voice_collect_seconds = float(merged.get("voice_collect_seconds", 2.5))
voice_max_collect_seconds = float(merged.get("voice_max_collect_seconds", 60.0))
wake_word = str(merged.get("wake_word", "jarvis")).strip().lower()
wake_aliases = [a.strip().lower() for a in _ensure_list(merged.get("wake_aliases")) if a.strip()]
wake_fuzzy_ratio = float(merged.get("wake_fuzzy_ratio", 0.78))
whisper_model = str(merged.get("whisper_model", "medium"))
whisper_backend = os.environ.get("JARVIS_WHISPER_BACKEND", "").lower() or str(merged.get("whisper_backend", "auto")).lower()
if whisper_backend not in ("auto", "mlx", "faster-whisper"):
whisper_backend = "auto"
whisper_device = str(merged.get("whisper_device", "auto")).lower()
if whisper_device not in ("cuda", "auto", "cpu"):
whisper_device = "auto"
whisper_compute_type = str(merged.get("whisper_compute_type", "int8"))
whisper_vad = bool(merged.get("whisper_vad", True))
voice_min_energy = float(merged.get("voice_min_energy", 0.02))
vad_enabled = bool(merged.get("vad_enabled", True))
vad_aggressiveness = int(merged.get("vad_aggressiveness", 2))
vad_frame_ms = int(merged.get("vad_frame_ms", 20))
vad_pre_roll_ms = int(merged.get("vad_pre_roll_ms", 240))
endpoint_silence_ms = int(merged.get("endpoint_silence_ms", 800))
max_utterance_ms = int(merged.get("max_utterance_ms", 12000))
tts_max_utterance_ms = int(merged.get("tts_max_utterance_ms", 3000))
sample_rate = int(merged.get("sample_rate", 16000))
tune_enabled = bool(merged.get("tune_enabled", True))
hot_window_enabled = bool(merged.get("hot_window_enabled", True))
hot_window_seconds = float(merged.get("hot_window_seconds", 3.0))
echo_energy_threshold = float(merged.get("echo_energy_threshold", 2.0))
echo_tolerance = float(merged.get("echo_tolerance", 0.3))
# Intent Judge - always used when available
intent_judge_model = str(merged.get("intent_judge_model", "gemma4:e2b"))
intent_judge_timeout_sec = float(merged.get("intent_judge_timeout_sec", 10.0))
# Transcript Buffer - ambient speech context for intent judge (separate from dialogue)
transcript_buffer_duration_sec = float(merged.get("transcript_buffer_duration_sec", 120.0))
# Dialogue memory window and forced diary update share this duration
dialogue_memory_timeout = float(merged.get("dialogue_memory_timeout", 300.0))
memory_enrichment_max_results = int(merged.get("memory_enrichment_max_results", 3))
memory_enrichment_source = str(merged.get("memory_enrichment_source", "all")).lower()
if memory_enrichment_source not in ("all", "diary", "graph"):
memory_enrichment_source = "all"
tool_carryover_max_turns = max(0, int(merged.get("tool_carryover_max_turns", 2)))
tool_carryover_per_entry_chars = max(200, int(merged.get("tool_carryover_per_entry_chars", 1200)))
_digest_raw = merged.get("memory_digest_enabled", None)
memory_digest_enabled: Optional[bool]
if _digest_raw is None:
memory_digest_enabled = None
else:
memory_digest_enabled = bool(_digest_raw)
_tool_digest_raw = merged.get("tool_result_digest_enabled", None)
tool_result_digest_enabled: Optional[bool]
if _tool_digest_raw is None:
tool_result_digest_enabled = None
else:
tool_result_digest_enabled = bool(_tool_digest_raw)
agentic_max_turns = int(merged.get("agentic_max_turns", 8))
tool_selection_strategy = str(merged.get("tool_selection_strategy", "llm")).lower()
if tool_selection_strategy not in ("all", "keyword", "embedding", "llm"):
tool_selection_strategy = "llm"
tool_router_model = str(merged.get("tool_router_model", "") or "").strip()
evaluator_model = str(merged.get("evaluator_model", "") or "").strip()
_eval_raw = merged.get("evaluator_enabled", None)
evaluator_enabled: Optional[bool]
if _eval_raw is None:
evaluator_enabled = None
else:
evaluator_enabled = bool(_eval_raw)
planner_model = str(merged.get("planner_model", "") or "").strip()
# Env override (PLANNER_ENABLED=0/1) so a latency-sensitive voice deployment
# can drop the pre-loop planner LLM call without editing the config file.
_planner_env = os.environ.get("PLANNER_ENABLED", "").strip().lower()
if _planner_env in ("0", "false", "no", "off"):
planner_enabled = False
elif _planner_env in ("1", "true", "yes", "on"):
planner_enabled = True
else:
planner_enabled = bool(merged.get("planner_enabled", True))
try:
planner_timeout_sec = float(merged.get("planner_timeout_sec", 6.0))
except (TypeError, ValueError):
planner_timeout_sec = 6.0
try:
tool_search_max_calls = int(merged.get("tool_search_max_calls", 3))
except (TypeError, ValueError):
tool_search_max_calls = 3
if tool_search_max_calls < 0:
tool_search_max_calls = 0
try:
evaluator_nudge_max = int(merged.get("evaluator_nudge_max", 2))
except (TypeError, ValueError):
evaluator_nudge_max = 2
if evaluator_nudge_max < 0:
evaluator_nudge_max = 0
location_enabled = bool(merged.get("location_enabled", True))
location_cache_minutes = int(merged.get("location_cache_minutes", 60))
location_ip_address_val = merged.get("location_ip_address")
location_ip_address = None if location_ip_address_val in (None, "", "null") else str(location_ip_address_val)
location_auto_detect = bool(merged.get("location_auto_detect", True))
location_cgnat_resolve_public_ip = bool(merged.get("location_cgnat_resolve_public_ip", True))
web_search_enabled = bool(merged.get("web_search_enabled", True))
brave_search_api_key = str(merged.get("brave_search_api_key", "") or "").strip()
wikipedia_fallback_enabled = bool(merged.get("wikipedia_fallback_enabled", True))
dictation_enabled = bool(merged.get("dictation_enabled", True))
dictation_hotkey = str(merged.get("dictation_hotkey", _default_dictation_hotkey())).strip()
dictation_filler_removal = bool(merged.get("dictation_filler_removal", False))
raw_dict = merged.get("dictation_custom_dictionary", [])
dictation_custom_dictionary = list(raw_dict) if isinstance(raw_dict, list) else []
mcps = _ensure_dict(merged.get("mcps"))
whisper_min_confidence = float(merged.get("whisper_min_confidence", 0.4))
whisper_no_speech_threshold = float(merged.get("whisper_no_speech_threshold", 0.5))
whisper_min_audio_duration = float(merged.get("whisper_min_audio_duration", 0.3))
whisper_min_word_length = int(merged.get("whisper_min_word_length", 2))
llm_chat_timeout_sec = float(merged.get("llm_chat_timeout_sec", 180.0))
llm_tools_timeout_sec = float(merged.get("llm_tools_timeout_sec", 300.0))
llm_digest_timeout_sec = float(merged.get("llm_digest_timeout_sec", 8.0))
llm_embedding_timeout_sec = float(merged.get("llm_embedding_timeout_sec", 60.0))
llm_profile_select_timeout_sec = float(merged.get("llm_profile_select_timeout_sec", 30.0))
try:
ollama_num_predict = int(merged.get("ollama_num_predict", 512))
except (TypeError, ValueError):
ollama_num_predict = 512
return Settings(
# Database & Storage
db_path=db_path,
sqlite_vss_path=sqlite_vss_path,
# LLM & AI Models
ollama_base_url=ollama_base_url,
ollama_embed_model=ollama_embed_model,
ollama_chat_model=ollama_chat_model,
llm_chat_timeout_sec=llm_chat_timeout_sec,
llm_tools_timeout_sec=llm_tools_timeout_sec,
llm_digest_timeout_sec=llm_digest_timeout_sec,
llm_embedding_timeout_sec=llm_embedding_timeout_sec,
llm_profile_select_timeout_sec=llm_profile_select_timeout_sec,
ollama_num_predict=ollama_num_predict,
# Profiles & Behavior
active_profiles=active_profiles,
use_stdin=use_stdin,
voice_debug=voice_debug,
# Screen Capture
allowlist_bundles=allowlist_bundles,
# Text-to-Speech
tts_enabled=tts_enabled,
tts_engine=tts_engine,
tts_voice=tts_voice,
tts_rate=tts_rate,
tts_chatterbox_device=tts_chatterbox_device,
tts_chatterbox_audio_prompt=tts_chatterbox_audio_prompt,
tts_chatterbox_exaggeration=tts_chatterbox_exaggeration,
tts_chatterbox_cfg_weight=tts_chatterbox_cfg_weight,
# Piper TTS
tts_piper_model_path=tts_piper_model_path,
tts_piper_speaker=tts_piper_speaker,
tts_piper_length_scale=tts_piper_length_scale,
tts_piper_noise_scale=tts_piper_noise_scale,
tts_piper_noise_w=tts_piper_noise_w,
tts_piper_sentence_silence=tts_piper_sentence_silence,
# Voice Input & Audio
voice_device=voice_device,
sample_rate=sample_rate,
voice_min_energy=voice_min_energy,
# Voice Collection & Timing
voice_block_seconds=voice_block_seconds,
voice_collect_seconds=voice_collect_seconds,
voice_max_collect_seconds=voice_max_collect_seconds,
# Wake Word Detection
wake_word=wake_word,
wake_aliases=wake_aliases,
wake_fuzzy_ratio=wake_fuzzy_ratio,
# Whisper Speech Recognition
whisper_model=whisper_model,
whisper_backend=whisper_backend,
whisper_device=whisper_device,
whisper_compute_type=whisper_compute_type,
whisper_vad=whisper_vad,
whisper_min_confidence=whisper_min_confidence,
whisper_no_speech_threshold=whisper_no_speech_threshold,
whisper_min_audio_duration=whisper_min_audio_duration,
whisper_min_word_length=whisper_min_word_length,
# Voice Activity Detection (VAD)
vad_enabled=vad_enabled,
vad_aggressiveness=vad_aggressiveness,
vad_frame_ms=vad_frame_ms,
vad_pre_roll_ms=vad_pre_roll_ms,
endpoint_silence_ms=endpoint_silence_ms,
max_utterance_ms=max_utterance_ms,
tts_max_utterance_ms=tts_max_utterance_ms,
# UI/UX Features
tune_enabled=tune_enabled,
hot_window_enabled=hot_window_enabled,
hot_window_seconds=hot_window_seconds,
echo_energy_threshold=echo_energy_threshold,
echo_tolerance=echo_tolerance,
# Intent Judge - always used when available
intent_judge_model=intent_judge_model,
intent_judge_timeout_sec=intent_judge_timeout_sec,
# Transcript Buffer
transcript_buffer_duration_sec=transcript_buffer_duration_sec,
# Memory & Dialogue
dialogue_memory_timeout=dialogue_memory_timeout,
memory_enrichment_max_results=memory_enrichment_max_results,
memory_enrichment_source=memory_enrichment_source,
tool_carryover_max_turns=tool_carryover_max_turns,
tool_carryover_per_entry_chars=tool_carryover_per_entry_chars,
memory_digest_enabled=memory_digest_enabled,
tool_result_digest_enabled=tool_result_digest_enabled,
agentic_max_turns=agentic_max_turns,
tool_selection_strategy=tool_selection_strategy,
tool_router_model=tool_router_model,
evaluator_model=evaluator_model,
evaluator_enabled=evaluator_enabled,
tool_search_max_calls=tool_search_max_calls,
evaluator_nudge_max=evaluator_nudge_max,
planner_model=planner_model,
planner_enabled=planner_enabled,
planner_timeout_sec=planner_timeout_sec,
# Location Services
location_enabled=location_enabled,
location_cache_minutes=location_cache_minutes,
location_ip_address=location_ip_address,
location_auto_detect=location_auto_detect,
location_cgnat_resolve_public_ip=location_cgnat_resolve_public_ip,
# Web Search
web_search_enabled=web_search_enabled,
brave_search_api_key=brave_search_api_key,
stream_browser=stream_browser,
gemini_auth=gemini_auth,
gemini_api_key=gemini_api_key,
gemini_model=gemini_model,
wikipedia_fallback_enabled=wikipedia_fallback_enabled,
# Dictation
dictation_enabled=dictation_enabled,
dictation_hotkey=dictation_hotkey,
dictation_filler_removal=dictation_filler_removal,
dictation_custom_dictionary=dictation_custom_dictionary,
# MCP Integration
mcps=mcps,
)