aman/src/aman.py
Thales Maciel ed1b59240b
Harden runtime diagnostics for milestone 3
Make the milestone 3 runtime story predictable instead of treating doctor, self-check, and startup failures as loosely related surfaces.

Split doctor and self-check into distinct read-only flows, add tri-state diagnostic status with stable IDs and next steps, and reuse that wording in CLI output, service logs, and tray-triggered diagnostics. Add non-mutating config/model probes, a make runtime-check gate, and public recovery/validation docs for the X11 GA roadmap.

Validation: make runtime-check; PYTHONPATH=src python3 -m unittest discover -s tests -p 'test_*.py'; python3 -m py_compile src/*.py tests/*.py; PYTHONPATH=src python3 -m aman doctor --help; PYTHONPATH=src python3 -m aman self-check --help. Leave milestone 3 open in the roadmap until the manual X11 validation rows are filled.
2026-03-12 17:41:23 -03:00

1767 lines
61 KiB
Python
Executable file

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import ast
import errno
import importlib.metadata
import inspect
import json
import logging
import os
import signal
import statistics
import sys
import threading
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from aiprocess import LlamaProcessor
from config import Config, ConfigValidationError, load, redacted_dict, save, validate
from constants import DEFAULT_CONFIG_PATH, MODEL_PATH, RECORD_TIMEOUT_SEC
from config_ui import ConfigUiResult, run_config_ui, show_about_dialog, show_help_dialog
from desktop import get_desktop_adapter
from diagnostics import (
doctor_command,
format_diagnostic_line,
format_support_line,
journalctl_command,
run_doctor,
run_self_check,
self_check_command,
verbose_run_command,
)
from engine.pipeline import PipelineEngine
from model_eval import (
build_heuristic_dataset,
format_model_eval_summary,
report_to_json,
run_model_eval,
)
from recorder import start_recording as start_audio_recording
from recorder import stop_recording as stop_audio_recording
from stages.asr_whisper import AsrResult, WhisperAsrStage
from stages.editor_llama import LlamaEditorStage
from vocabulary import VocabularyEngine
class State:
IDLE = "idle"
RECORDING = "recording"
STT = "stt"
PROCESSING = "processing"
OUTPUTTING = "outputting"
_LOCK_HANDLE = None
@dataclass
class TranscriptProcessTimings:
asr_ms: float
alignment_ms: float
alignment_applied: int
fact_guard_ms: float
fact_guard_action: str
fact_guard_violations: int
editor_ms: float
editor_pass1_ms: float
editor_pass2_ms: float
vocabulary_ms: float
total_ms: float
@dataclass
class BenchRunMetrics:
run_index: int
input_chars: int
asr_ms: float
alignment_ms: float
alignment_applied: int
fact_guard_ms: float
fact_guard_action: str
fact_guard_violations: int
editor_ms: float
editor_pass1_ms: float
editor_pass2_ms: float
vocabulary_ms: float
total_ms: float
output_chars: int
@dataclass
class BenchSummary:
runs: int
min_total_ms: float
max_total_ms: float
avg_total_ms: float
p50_total_ms: float
p95_total_ms: float
avg_asr_ms: float
avg_alignment_ms: float
avg_alignment_applied: float
avg_fact_guard_ms: float
avg_fact_guard_violations: float
fallback_runs: int
rejected_runs: int
avg_editor_ms: float
avg_editor_pass1_ms: float
avg_editor_pass2_ms: float
avg_vocabulary_ms: float
@dataclass
class BenchReport:
config_path: str
editor_backend: str
profile: str
stt_language: str
warmup_runs: int
measured_runs: int
runs: list[BenchRunMetrics]
summary: BenchSummary
def _build_whisper_model(model_name: str, device: str):
try:
from faster_whisper import WhisperModel # type: ignore[import-not-found]
except ModuleNotFoundError as exc:
raise RuntimeError(
"faster-whisper is not installed; install dependencies with `uv sync`"
) from exc
return WhisperModel(
model_name,
device=device,
compute_type=_compute_type(device),
)
def _compute_type(device: str) -> str:
dev = (device or "cpu").lower()
if dev.startswith("cuda"):
return "float16"
return "int8"
def _process_transcript_pipeline(
text: str,
*,
stt_lang: str,
pipeline: PipelineEngine,
suppress_ai_errors: bool,
asr_result: AsrResult | None = None,
asr_ms: float = 0.0,
verbose: bool = False,
) -> tuple[str, TranscriptProcessTimings]:
processed = (text or "").strip()
if not processed:
return processed, TranscriptProcessTimings(
asr_ms=asr_ms,
alignment_ms=0.0,
alignment_applied=0,
fact_guard_ms=0.0,
fact_guard_action="accepted",
fact_guard_violations=0,
editor_ms=0.0,
editor_pass1_ms=0.0,
editor_pass2_ms=0.0,
vocabulary_ms=0.0,
total_ms=asr_ms,
)
try:
if asr_result is not None:
result = pipeline.run_asr_result(asr_result)
else:
result = pipeline.run_transcript(processed, language=stt_lang)
except Exception as exc:
if suppress_ai_errors:
logging.error("editor stage failed: %s", exc)
return processed, TranscriptProcessTimings(
asr_ms=asr_ms,
alignment_ms=0.0,
alignment_applied=0,
fact_guard_ms=0.0,
fact_guard_action="accepted",
fact_guard_violations=0,
editor_ms=0.0,
editor_pass1_ms=0.0,
editor_pass2_ms=0.0,
vocabulary_ms=0.0,
total_ms=asr_ms,
)
raise
processed = result.output_text
editor_ms = result.editor.latency_ms if result.editor else 0.0
editor_pass1_ms = result.editor.pass1_ms if result.editor else 0.0
editor_pass2_ms = result.editor.pass2_ms if result.editor else 0.0
if verbose and result.alignment_decisions:
preview = "; ".join(
decision.reason for decision in result.alignment_decisions[:3]
)
logging.debug(
"alignment: applied=%d skipped=%d decisions=%d preview=%s",
result.alignment_applied,
result.alignment_skipped,
len(result.alignment_decisions),
preview,
)
if verbose and result.fact_guard_violations > 0:
preview = "; ".join(item.reason for item in result.fact_guard_details[:3])
logging.debug(
"fact_guard: action=%s violations=%d preview=%s",
result.fact_guard_action,
result.fact_guard_violations,
preview,
)
total_ms = asr_ms + result.total_ms
return processed, TranscriptProcessTimings(
asr_ms=asr_ms,
alignment_ms=result.alignment_ms,
alignment_applied=result.alignment_applied,
fact_guard_ms=result.fact_guard_ms,
fact_guard_action=result.fact_guard_action,
fact_guard_violations=result.fact_guard_violations,
editor_ms=editor_ms,
editor_pass1_ms=editor_pass1_ms,
editor_pass2_ms=editor_pass2_ms,
vocabulary_ms=result.vocabulary_ms,
total_ms=total_ms,
)
def _percentile(values: list[float], quantile: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
idx = int(round((len(ordered) - 1) * quantile))
idx = min(max(idx, 0), len(ordered) - 1)
return ordered[idx]
def _summarize_bench_runs(runs: list[BenchRunMetrics]) -> BenchSummary:
if not runs:
return BenchSummary(
runs=0,
min_total_ms=0.0,
max_total_ms=0.0,
avg_total_ms=0.0,
p50_total_ms=0.0,
p95_total_ms=0.0,
avg_asr_ms=0.0,
avg_alignment_ms=0.0,
avg_alignment_applied=0.0,
avg_fact_guard_ms=0.0,
avg_fact_guard_violations=0.0,
fallback_runs=0,
rejected_runs=0,
avg_editor_ms=0.0,
avg_editor_pass1_ms=0.0,
avg_editor_pass2_ms=0.0,
avg_vocabulary_ms=0.0,
)
totals = [item.total_ms for item in runs]
asr = [item.asr_ms for item in runs]
alignment = [item.alignment_ms for item in runs]
alignment_applied = [item.alignment_applied for item in runs]
fact_guard = [item.fact_guard_ms for item in runs]
fact_guard_violations = [item.fact_guard_violations for item in runs]
fallback_runs = sum(1 for item in runs if item.fact_guard_action == "fallback")
rejected_runs = sum(1 for item in runs if item.fact_guard_action == "rejected")
editor = [item.editor_ms for item in runs]
editor_pass1 = [item.editor_pass1_ms for item in runs]
editor_pass2 = [item.editor_pass2_ms for item in runs]
vocab = [item.vocabulary_ms for item in runs]
return BenchSummary(
runs=len(runs),
min_total_ms=min(totals),
max_total_ms=max(totals),
avg_total_ms=sum(totals) / len(totals),
p50_total_ms=statistics.median(totals),
p95_total_ms=_percentile(totals, 0.95),
avg_asr_ms=sum(asr) / len(asr),
avg_alignment_ms=sum(alignment) / len(alignment),
avg_alignment_applied=sum(alignment_applied) / len(alignment_applied),
avg_fact_guard_ms=sum(fact_guard) / len(fact_guard),
avg_fact_guard_violations=sum(fact_guard_violations) / len(fact_guard_violations),
fallback_runs=fallback_runs,
rejected_runs=rejected_runs,
avg_editor_ms=sum(editor) / len(editor),
avg_editor_pass1_ms=sum(editor_pass1) / len(editor_pass1),
avg_editor_pass2_ms=sum(editor_pass2) / len(editor_pass2),
avg_vocabulary_ms=sum(vocab) / len(vocab),
)
class Daemon:
def __init__(
self,
cfg: Config,
desktop,
*,
verbose: bool = False,
config_path: Path | None = None,
):
self.cfg = cfg
self.desktop = desktop
self.verbose = verbose
self.config_path = config_path or DEFAULT_CONFIG_PATH
self.lock = threading.Lock()
self._shutdown_requested = threading.Event()
self._paused = False
self.state = State.IDLE
self.stream = None
self.record = None
self.timer: threading.Timer | None = None
self.vocabulary = VocabularyEngine(cfg.vocabulary)
self._stt_hint_kwargs_cache: dict[str, Any] | None = None
self.model = _build_whisper_model(
_resolve_whisper_model_spec(cfg),
cfg.stt.device,
)
self.asr_stage = WhisperAsrStage(
self.model,
configured_language=cfg.stt.language,
hint_kwargs_provider=self._stt_hint_kwargs,
)
logging.info("initializing editor stage (local_llama_builtin)")
self.editor_stage = _build_editor_stage(cfg, verbose=self.verbose)
self._warmup_editor_stage()
self.pipeline = PipelineEngine(
asr_stage=self.asr_stage,
editor_stage=self.editor_stage,
vocabulary=self.vocabulary,
safety_enabled=cfg.safety.enabled,
safety_strict=cfg.safety.strict,
)
logging.info("editor stage ready")
self.log_transcript = verbose
def _arm_cancel_listener(self) -> bool:
try:
self.desktop.start_cancel_listener(lambda: self.cancel_recording())
return True
except Exception as exc:
logging.error("failed to start cancel listener: %s", exc)
return False
def _disarm_cancel_listener(self):
try:
self.desktop.stop_cancel_listener()
except Exception as exc:
logging.debug("failed to stop cancel listener: %s", exc)
def set_state(self, state: str):
with self.lock:
prev = self.state
self.state = state
if prev != state:
logging.debug("state: %s -> %s", prev, state)
else:
logging.debug("redundant state set: %s", state)
def get_state(self):
with self.lock:
return self.state
def request_shutdown(self):
self._shutdown_requested.set()
def is_paused(self) -> bool:
with self.lock:
return self._paused
def toggle_paused(self) -> bool:
with self.lock:
self._paused = not self._paused
paused = self._paused
logging.info("pause %s", "enabled" if paused else "disabled")
return paused
def apply_config(self, cfg: Config) -> None:
new_model = _build_whisper_model(
_resolve_whisper_model_spec(cfg),
cfg.stt.device,
)
new_vocabulary = VocabularyEngine(cfg.vocabulary)
new_stt_hint_kwargs_cache: dict[str, Any] | None = None
def _hint_kwargs_provider() -> dict[str, Any]:
nonlocal new_stt_hint_kwargs_cache
if new_stt_hint_kwargs_cache is not None:
return new_stt_hint_kwargs_cache
hotwords, initial_prompt = new_vocabulary.build_stt_hints()
if not hotwords and not initial_prompt:
new_stt_hint_kwargs_cache = {}
return new_stt_hint_kwargs_cache
try:
signature = inspect.signature(new_model.transcribe)
except (TypeError, ValueError):
logging.debug("stt signature inspection failed; skipping hints")
new_stt_hint_kwargs_cache = {}
return new_stt_hint_kwargs_cache
params = signature.parameters
kwargs: dict[str, Any] = {}
if hotwords and "hotwords" in params:
kwargs["hotwords"] = hotwords
if initial_prompt and "initial_prompt" in params:
kwargs["initial_prompt"] = initial_prompt
if not kwargs:
logging.debug("stt hint arguments are not supported by this whisper runtime")
new_stt_hint_kwargs_cache = kwargs
return new_stt_hint_kwargs_cache
new_asr_stage = WhisperAsrStage(
new_model,
configured_language=cfg.stt.language,
hint_kwargs_provider=_hint_kwargs_provider,
)
new_editor_stage = _build_editor_stage(cfg, verbose=self.verbose)
new_editor_stage.warmup()
new_pipeline = PipelineEngine(
asr_stage=new_asr_stage,
editor_stage=new_editor_stage,
vocabulary=new_vocabulary,
safety_enabled=cfg.safety.enabled,
safety_strict=cfg.safety.strict,
)
with self.lock:
self.cfg = cfg
self.model = new_model
self.vocabulary = new_vocabulary
self._stt_hint_kwargs_cache = None
self.asr_stage = new_asr_stage
self.editor_stage = new_editor_stage
self.pipeline = new_pipeline
logging.info("applied new runtime config")
def toggle(self):
should_stop = False
with self.lock:
if self._shutdown_requested.is_set():
logging.info("shutdown in progress, trigger ignored")
return
if self.state == State.IDLE:
if self._paused:
logging.info("paused, trigger ignored")
return
self._start_recording_locked()
return
if self.state == State.RECORDING:
should_stop = True
else:
logging.info("busy (%s), trigger ignored", self.state)
if should_stop:
self.stop_recording(trigger="user")
def _start_recording_locked(self):
if self.state != State.IDLE:
logging.info("busy (%s), trigger ignored", self.state)
return
try:
stream, record = start_audio_recording(self.cfg.recording.input)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"audio.input",
f"record start failed: {exc}",
next_step=f"run `{doctor_command(self.config_path)}` and verify the selected input device",
)
return
if not self._arm_cancel_listener():
try:
stream.stop()
except Exception:
pass
try:
stream.close()
except Exception:
pass
logging.error("recording start aborted because cancel listener is unavailable")
return
self.stream = stream
self.record = record
prev = self.state
self.state = State.RECORDING
logging.debug("state: %s -> %s", prev, self.state)
logging.info("recording started")
if self.timer:
self.timer.cancel()
self.timer = threading.Timer(RECORD_TIMEOUT_SEC, self._timeout_stop)
self.timer.daemon = True
self.timer.start()
def _timeout_stop(self):
self.stop_recording(trigger="timeout")
def _start_stop_worker(self, stream: Any, record: Any, trigger: str, process_audio: bool):
threading.Thread(
target=self._stop_and_process,
args=(stream, record, trigger, process_audio),
daemon=True,
).start()
def _begin_stop_locked(self):
if self.state != State.RECORDING:
return None
stream = self.stream
record = self.record
self.stream = None
self.record = None
if self.timer:
self.timer.cancel()
self.timer = None
self._disarm_cancel_listener()
prev = self.state
self.state = State.STT
logging.debug("state: %s -> %s", prev, self.state)
if stream is None or record is None:
logging.warning("recording resources are unavailable during stop")
self.state = State.IDLE
return None
return stream, record
def _stop_and_process(self, stream: Any, record: Any, trigger: str, process_audio: bool):
logging.info("stopping recording (%s)", trigger)
try:
audio = stop_audio_recording(stream, record)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"runtime.audio",
f"record stop failed: {exc}",
next_step=f"rerun `{doctor_command(self.config_path)}` and verify the audio runtime",
)
self.set_state(State.IDLE)
return
if not process_audio or self._shutdown_requested.is_set():
self.set_state(State.IDLE)
return
if audio.size == 0:
_log_support_issue(
logging.ERROR,
"runtime.audio",
"no audio was captured from the active input device",
next_step="verify the selected microphone level and rerun diagnostics",
)
self.set_state(State.IDLE)
return
try:
logging.info("stt started")
asr_result = self._transcribe_with_metrics(audio)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"startup.readiness",
f"stt failed: {exc}",
next_step=f"run `{self_check_command(self.config_path)}` and then `{verbose_run_command(self.config_path)}`",
)
self.set_state(State.IDLE)
return
text = (asr_result.raw_text or "").strip()
stt_lang = asr_result.language
if not text:
self.set_state(State.IDLE)
return
if self.log_transcript:
logging.debug("stt: %s", text)
else:
logging.info("stt produced %d chars", len(text))
if not self._shutdown_requested.is_set():
self.set_state(State.PROCESSING)
logging.info("editor stage started")
try:
text, _timings = _process_transcript_pipeline(
text,
stt_lang=stt_lang,
pipeline=self.pipeline,
suppress_ai_errors=False,
asr_result=asr_result,
asr_ms=asr_result.latency_ms,
verbose=self.log_transcript,
)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"model.cache",
f"editor stage failed: {exc}",
next_step=f"run `{self_check_command(self.config_path)}` and inspect `{journalctl_command()}` if the service keeps failing",
)
self.set_state(State.IDLE)
return
if self.log_transcript:
logging.debug("processed: %s", text)
else:
logging.info("processed text length: %d", len(text))
if self._shutdown_requested.is_set():
self.set_state(State.IDLE)
return
try:
self.set_state(State.OUTPUTTING)
logging.info("outputting started")
backend = self.cfg.injection.backend
self.desktop.inject_text(
text,
backend,
remove_transcription_from_clipboard=(
self.cfg.injection.remove_transcription_from_clipboard
),
)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"injection.backend",
f"output failed: {exc}",
next_step=f"run `{doctor_command(self.config_path)}` and then `{verbose_run_command(self.config_path)}`",
)
finally:
self.set_state(State.IDLE)
def stop_recording(self, *, trigger: str = "user", process_audio: bool = True):
payload = None
with self.lock:
payload = self._begin_stop_locked()
if payload is None:
return
stream, record = payload
self._start_stop_worker(stream, record, trigger, process_audio)
def cancel_recording(self):
with self.lock:
if self.state != State.RECORDING:
return
self.stop_recording(trigger="cancel", process_audio=False)
def shutdown(self, timeout: float = 5.0) -> bool:
self.request_shutdown()
self._disarm_cancel_listener()
self.stop_recording(trigger="shutdown", process_audio=False)
return self.wait_for_idle(timeout)
def wait_for_idle(self, timeout: float) -> bool:
end = time.time() + timeout
while time.time() < end:
if self.get_state() == State.IDLE:
return True
time.sleep(0.05)
return self.get_state() == State.IDLE
def _transcribe_with_metrics(self, audio) -> AsrResult:
return self.asr_stage.transcribe(audio)
def _transcribe(self, audio) -> tuple[str, str]:
result = self._transcribe_with_metrics(audio)
return result.raw_text, result.language
def _warmup_editor_stage(self) -> None:
logging.info("warming up editor stage")
try:
self.editor_stage.warmup()
except Exception as exc:
if self.cfg.advanced.strict_startup:
raise RuntimeError(f"editor stage warmup failed: {exc}") from exc
logging.warning(
"editor stage warmup failed, continuing because advanced.strict_startup=false: %s",
exc,
)
return
logging.info("editor stage warmup completed")
def _stt_hint_kwargs(self) -> dict[str, Any]:
if self._stt_hint_kwargs_cache is not None:
return self._stt_hint_kwargs_cache
hotwords, initial_prompt = self.vocabulary.build_stt_hints()
if not hotwords and not initial_prompt:
self._stt_hint_kwargs_cache = {}
return self._stt_hint_kwargs_cache
try:
signature = inspect.signature(self.model.transcribe)
except (TypeError, ValueError):
logging.debug("stt signature inspection failed; skipping hints")
self._stt_hint_kwargs_cache = {}
return self._stt_hint_kwargs_cache
params = signature.parameters
kwargs: dict[str, Any] = {}
if hotwords and "hotwords" in params:
kwargs["hotwords"] = hotwords
if initial_prompt and "initial_prompt" in params:
kwargs["initial_prompt"] = initial_prompt
if not kwargs:
logging.debug("stt hint arguments are not supported by this whisper runtime")
self._stt_hint_kwargs_cache = kwargs
return self._stt_hint_kwargs_cache
def _read_lock_pid(lock_file) -> str:
lock_file.seek(0)
return lock_file.read().strip()
def _lock_single_instance():
runtime_dir = Path(os.getenv("XDG_RUNTIME_DIR", "/tmp")) / "aman"
runtime_dir.mkdir(parents=True, exist_ok=True)
lock_path = runtime_dir / "aman.lock"
lock_file = open(lock_path, "a+", encoding="utf-8")
try:
import fcntl
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
pid = _read_lock_pid(lock_file)
lock_file.close()
if pid:
raise SystemExit(f"already running (pid={pid})") from exc
raise SystemExit("already running") from exc
except OSError as exc:
if exc.errno in (errno.EACCES, errno.EAGAIN):
pid = _read_lock_pid(lock_file)
lock_file.close()
if pid:
raise SystemExit(f"already running (pid={pid})") from exc
raise SystemExit("already running") from exc
raise
lock_file.seek(0)
lock_file.truncate()
lock_file.write(f"{os.getpid()}\n")
lock_file.flush()
return lock_file
def _resolve_whisper_model_spec(cfg: Config) -> str:
if cfg.stt.provider != "local_whisper":
raise RuntimeError(f"unsupported stt provider: {cfg.stt.provider}")
custom_path = cfg.models.whisper_model_path.strip()
if not custom_path:
return cfg.stt.model
if not cfg.models.allow_custom_models:
raise RuntimeError("custom whisper model path requires models.allow_custom_models=true")
path = Path(custom_path)
if not path.exists():
raise RuntimeError(f"custom whisper model path does not exist: {path}")
return str(path)
def _build_editor_stage(cfg: Config, *, verbose: bool) -> LlamaEditorStage:
processor = LlamaProcessor(
verbose=verbose,
model_path=None,
)
return LlamaEditorStage(
processor,
profile=cfg.ux.profile,
)
def _app_version() -> str:
try:
return importlib.metadata.version("aman")
except importlib.metadata.PackageNotFoundError:
return "0.0.0-dev"
def _read_json_file(path: Path) -> Any:
if not path.exists():
raise RuntimeError(f"file does not exist: {path}")
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
raise RuntimeError(f"invalid json file '{path}': {exc}") from exc
def _load_winner_name(report_path: Path) -> str:
payload = _read_json_file(report_path)
if not isinstance(payload, dict):
raise RuntimeError(f"model report must be an object: {report_path}")
winner = payload.get("winner_recommendation")
if not isinstance(winner, dict):
raise RuntimeError(
f"report is missing winner_recommendation object: {report_path}"
)
winner_name = str(winner.get("name", "")).strip()
if not winner_name:
raise RuntimeError(
f"winner_recommendation.name is missing in report: {report_path}"
)
return winner_name
def _load_model_artifact(artifacts_path: Path, model_name: str) -> dict[str, str]:
payload = _read_json_file(artifacts_path)
if not isinstance(payload, dict):
raise RuntimeError(f"artifact registry must be an object: {artifacts_path}")
models_raw = payload.get("models")
if not isinstance(models_raw, list):
raise RuntimeError(f"artifact registry missing 'models' array: {artifacts_path}")
wanted = model_name.strip().casefold()
for row in models_raw:
if not isinstance(row, dict):
continue
name = str(row.get("name", "")).strip()
if not name:
continue
if name.casefold() != wanted:
continue
filename = str(row.get("filename", "")).strip()
url = str(row.get("url", "")).strip()
sha256 = str(row.get("sha256", "")).strip().lower()
is_hex = len(sha256) == 64 and all(ch in "0123456789abcdef" for ch in sha256)
if not filename or not url or not is_hex:
raise RuntimeError(
f"artifact '{name}' is missing filename/url/sha256 in {artifacts_path}"
)
return {
"name": name,
"filename": filename,
"url": url,
"sha256": sha256,
}
raise RuntimeError(
f"winner '{model_name}' is not present in artifact registry: {artifacts_path}"
)
def _load_model_constants(constants_path: Path) -> dict[str, str]:
if not constants_path.exists():
raise RuntimeError(f"constants file does not exist: {constants_path}")
source = constants_path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(constants_path))
except Exception as exc:
raise RuntimeError(f"failed to parse constants module '{constants_path}': {exc}") from exc
target_names = {"MODEL_NAME", "MODEL_URL", "MODEL_SHA256"}
values: dict[str, str] = {}
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if not isinstance(target, ast.Name):
continue
if target.id not in target_names:
continue
try:
value = ast.literal_eval(node.value)
except Exception as exc:
raise RuntimeError(
f"failed to evaluate {target.id} from {constants_path}: {exc}"
) from exc
if not isinstance(value, str):
raise RuntimeError(f"{target.id} must be a string in {constants_path}")
values[target.id] = value
missing = sorted(name for name in target_names if name not in values)
if missing:
raise RuntimeError(
f"constants file is missing required assignments: {', '.join(missing)}"
)
return values
def _write_model_constants(
constants_path: Path,
*,
model_name: str,
model_url: str,
model_sha256: str,
) -> None:
source = constants_path.read_text(encoding="utf-8")
try:
tree = ast.parse(source, filename=str(constants_path))
except Exception as exc:
raise RuntimeError(f"failed to parse constants module '{constants_path}': {exc}") from exc
line_ranges: dict[str, tuple[int, int]] = {}
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
start = getattr(node, "lineno", None)
end = getattr(node, "end_lineno", None)
if start is None or end is None:
continue
for target in node.targets:
if not isinstance(target, ast.Name):
continue
if target.id in {"MODEL_NAME", "MODEL_URL", "MODEL_SHA256"}:
line_ranges[target.id] = (int(start), int(end))
missing = sorted(
name for name in ("MODEL_NAME", "MODEL_URL", "MODEL_SHA256") if name not in line_ranges
)
if missing:
raise RuntimeError(
f"constants file is missing assignments to update: {', '.join(missing)}"
)
lines = source.splitlines()
replacements = {
"MODEL_NAME": f'MODEL_NAME = "{model_name}"',
"MODEL_URL": f'MODEL_URL = "{model_url}"',
"MODEL_SHA256": f'MODEL_SHA256 = "{model_sha256}"',
}
for key in sorted(line_ranges, key=lambda item: line_ranges[item][0], reverse=True):
start, end = line_ranges[key]
lines[start - 1 : end] = [replacements[key]]
rendered = "\n".join(lines)
if source.endswith("\n"):
rendered = f"{rendered}\n"
constants_path.write_text(rendered, encoding="utf-8")
def _sync_default_model_command(args: argparse.Namespace) -> int:
report_path = Path(args.report)
artifacts_path = Path(args.artifacts)
constants_path = Path(args.constants)
try:
winner_name = _load_winner_name(report_path)
artifact = _load_model_artifact(artifacts_path, winner_name)
current = _load_model_constants(constants_path)
except Exception as exc:
logging.error("sync-default-model failed: %s", exc)
return 1
expected = {
"MODEL_NAME": artifact["filename"],
"MODEL_URL": artifact["url"],
"MODEL_SHA256": artifact["sha256"],
}
changed_fields = [
key
for key in ("MODEL_NAME", "MODEL_URL", "MODEL_SHA256")
if str(current.get(key, "")).strip() != str(expected[key]).strip()
]
in_sync = len(changed_fields) == 0
summary = {
"report": str(report_path),
"artifacts": str(artifacts_path),
"constants": str(constants_path),
"winner_name": winner_name,
"in_sync": in_sync,
"changed_fields": changed_fields,
}
if args.check:
if args.json:
print(json.dumps(summary, indent=2, ensure_ascii=False))
if in_sync:
logging.info("default model constants are in sync with winner '%s'", winner_name)
return 0
logging.error(
"default model constants are out of sync with winner '%s' (%s)",
winner_name,
", ".join(changed_fields),
)
return 2
if in_sync:
logging.info("default model already matches winner '%s'", winner_name)
else:
try:
_write_model_constants(
constants_path,
model_name=artifact["filename"],
model_url=artifact["url"],
model_sha256=artifact["sha256"],
)
except Exception as exc:
logging.error("sync-default-model failed while writing constants: %s", exc)
return 1
logging.info(
"default model updated to '%s' (%s)",
winner_name,
", ".join(changed_fields),
)
summary["updated"] = True
if args.json:
print(json.dumps(summary, indent=2, ensure_ascii=False))
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command")
run_parser = subparsers.add_parser(
"run",
help="run Aman in the foreground for setup, support, or debugging",
description="Run Aman in the foreground for setup, support, or debugging.",
)
run_parser.add_argument("--config", default="", help="path to config.json")
run_parser.add_argument("--dry-run", action="store_true", help="log hotkey only")
run_parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logs")
doctor_parser = subparsers.add_parser(
"doctor",
help="run fast preflight diagnostics for config and local environment",
description="Run fast preflight diagnostics for config and the local environment.",
)
doctor_parser.add_argument("--config", default="", help="path to config.json")
doctor_parser.add_argument("--json", action="store_true", help="print JSON output")
doctor_parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logs")
self_check_parser = subparsers.add_parser(
"self-check",
help="run deeper installed-system readiness diagnostics without modifying local state",
description="Run deeper installed-system readiness diagnostics without modifying local state.",
)
self_check_parser.add_argument("--config", default="", help="path to config.json")
self_check_parser.add_argument("--json", action="store_true", help="print JSON output")
self_check_parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logs")
bench_parser = subparsers.add_parser(
"bench",
help="run the processing flow from input text without stt or injection",
)
bench_parser.add_argument("--config", default="", help="path to config.json")
bench_input = bench_parser.add_mutually_exclusive_group(required=True)
bench_input.add_argument("--text", default="", help="input transcript text")
bench_input.add_argument("--text-file", default="", help="path to transcript text file")
bench_parser.add_argument("--repeat", type=int, default=1, help="number of measured runs")
bench_parser.add_argument("--warmup", type=int, default=1, help="number of warmup runs")
bench_parser.add_argument("--json", action="store_true", help="print JSON output")
bench_parser.add_argument(
"--print-output",
action="store_true",
help="print final processed output text",
)
bench_parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logs")
eval_parser = subparsers.add_parser(
"eval-models",
help="evaluate model/parameter matrices against expected outputs",
)
eval_parser.add_argument("--dataset", required=True, help="path to evaluation dataset (.jsonl)")
eval_parser.add_argument("--matrix", required=True, help="path to model matrix (.json)")
eval_parser.add_argument(
"--heuristic-dataset",
default="",
help="optional path to heuristic alignment dataset (.jsonl)",
)
eval_parser.add_argument(
"--heuristic-weight",
type=float,
default=0.25,
help="weight for heuristic score contribution to combined ranking (0.0-1.0)",
)
eval_parser.add_argument(
"--report-version",
type=int,
default=2,
help="report schema version to emit",
)
eval_parser.add_argument("--output", default="", help="optional path to write full JSON report")
eval_parser.add_argument("--json", action="store_true", help="print JSON output")
eval_parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logs")
heuristic_builder = subparsers.add_parser(
"build-heuristic-dataset",
help="build a canonical heuristic dataset from a raw JSONL source",
)
heuristic_builder.add_argument("--input", required=True, help="path to raw heuristic dataset (.jsonl)")
heuristic_builder.add_argument("--output", required=True, help="path to canonical heuristic dataset (.jsonl)")
heuristic_builder.add_argument("--json", action="store_true", help="print JSON summary output")
heuristic_builder.add_argument("-v", "--verbose", action="store_true", help="enable verbose logs")
sync_model_parser = subparsers.add_parser(
"sync-default-model",
help="sync managed editor model constants with benchmark winner report",
)
sync_model_parser.add_argument(
"--report",
default="benchmarks/results/latest.json",
help="path to winner report JSON",
)
sync_model_parser.add_argument(
"--artifacts",
default="benchmarks/model_artifacts.json",
help="path to model artifact registry JSON",
)
sync_model_parser.add_argument(
"--constants",
default="src/constants.py",
help="path to constants module to update/check",
)
sync_model_parser.add_argument(
"--check",
action="store_true",
help="check only; exit non-zero if constants do not match winner",
)
sync_model_parser.add_argument("--json", action="store_true", help="print JSON summary output")
subparsers.add_parser("version", help="print aman version")
init_parser = subparsers.add_parser("init", help="write a default config")
init_parser.add_argument("--config", default="", help="path to config.json")
init_parser.add_argument("--force", action="store_true", help="overwrite existing config")
return parser
def _parse_cli_args(argv: list[str]) -> argparse.Namespace:
parser = _build_parser()
normalized_argv = list(argv)
known_commands = {
"run",
"doctor",
"self-check",
"bench",
"eval-models",
"build-heuristic-dataset",
"sync-default-model",
"version",
"init",
}
if not normalized_argv or normalized_argv[0] not in known_commands:
normalized_argv = ["run", *normalized_argv]
return parser.parse_args(normalized_argv)
def _configure_logging(verbose: bool) -> None:
logging.basicConfig(
stream=sys.stderr,
level=logging.DEBUG if verbose else logging.INFO,
format="aman: %(asctime)s %(levelname)s %(message)s",
)
def _log_support_issue(
level: int,
issue_id: str,
message: str,
*,
next_step: str = "",
) -> None:
logging.log(level, format_support_line(issue_id, message, next_step=next_step))
def _diagnostic_command(
args: argparse.Namespace,
runner,
) -> int:
report = runner(args.config)
if args.json:
print(report.to_json())
else:
for check in report.checks:
print(format_diagnostic_line(check))
print(f"overall: {report.status}")
return 0 if report.ok else 2
def _doctor_command(args: argparse.Namespace) -> int:
return _diagnostic_command(args, run_doctor)
def _self_check_command(args: argparse.Namespace) -> int:
return _diagnostic_command(args, run_self_check)
def _read_bench_input_text(args: argparse.Namespace) -> str:
if args.text_file:
try:
return Path(args.text_file).read_text(encoding="utf-8")
except Exception as exc:
raise RuntimeError(f"failed to read bench text file '{args.text_file}': {exc}") from exc
return args.text
def _bench_command(args: argparse.Namespace) -> int:
config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH
if args.repeat < 1:
logging.error("bench failed: --repeat must be >= 1")
return 1
if args.warmup < 0:
logging.error("bench failed: --warmup must be >= 0")
return 1
try:
cfg = load(str(config_path))
validate(cfg)
except ConfigValidationError as exc:
logging.error("bench failed: invalid config field '%s': %s", exc.field, exc.reason)
if exc.example_fix:
logging.error("bench example fix: %s", exc.example_fix)
return 1
except Exception as exc:
logging.error("bench failed: %s", exc)
return 1
try:
transcript_input = _read_bench_input_text(args)
except Exception as exc:
logging.error("bench failed: %s", exc)
return 1
if not transcript_input.strip():
logging.error("bench failed: input transcript cannot be empty")
return 1
try:
editor_stage = _build_editor_stage(cfg, verbose=args.verbose)
editor_stage.warmup()
except Exception as exc:
logging.error("bench failed: could not initialize editor stage: %s", exc)
return 1
vocabulary = VocabularyEngine(cfg.vocabulary)
pipeline = PipelineEngine(
asr_stage=None,
editor_stage=editor_stage,
vocabulary=vocabulary,
safety_enabled=cfg.safety.enabled,
safety_strict=cfg.safety.strict,
)
stt_lang = cfg.stt.language
logging.info(
"bench started: editor=local_llama_builtin profile=%s language=%s warmup=%d repeat=%d",
cfg.ux.profile,
stt_lang,
args.warmup,
args.repeat,
)
for run_idx in range(args.warmup):
try:
_process_transcript_pipeline(
transcript_input,
stt_lang=stt_lang,
pipeline=pipeline,
suppress_ai_errors=False,
verbose=args.verbose,
)
except Exception as exc:
logging.error("bench failed during warmup run %d: %s", run_idx + 1, exc)
return 2
runs: list[BenchRunMetrics] = []
last_output = ""
for run_idx in range(args.repeat):
try:
output, timings = _process_transcript_pipeline(
transcript_input,
stt_lang=stt_lang,
pipeline=pipeline,
suppress_ai_errors=False,
verbose=args.verbose,
)
except Exception as exc:
logging.error("bench failed during measured run %d: %s", run_idx + 1, exc)
return 2
last_output = output
metric = BenchRunMetrics(
run_index=run_idx + 1,
input_chars=len(transcript_input),
asr_ms=timings.asr_ms,
alignment_ms=timings.alignment_ms,
alignment_applied=timings.alignment_applied,
fact_guard_ms=timings.fact_guard_ms,
fact_guard_action=timings.fact_guard_action,
fact_guard_violations=timings.fact_guard_violations,
editor_ms=timings.editor_ms,
editor_pass1_ms=timings.editor_pass1_ms,
editor_pass2_ms=timings.editor_pass2_ms,
vocabulary_ms=timings.vocabulary_ms,
total_ms=timings.total_ms,
output_chars=len(output),
)
runs.append(metric)
logging.debug(
"bench run %d/%d: asr=%.2fms align=%.2fms applied=%d guard=%.2fms "
"(action=%s violations=%d) editor=%.2fms "
"(pass1=%.2fms pass2=%.2fms) vocab=%.2fms total=%.2fms",
metric.run_index,
args.repeat,
metric.asr_ms,
metric.alignment_ms,
metric.alignment_applied,
metric.fact_guard_ms,
metric.fact_guard_action,
metric.fact_guard_violations,
metric.editor_ms,
metric.editor_pass1_ms,
metric.editor_pass2_ms,
metric.vocabulary_ms,
metric.total_ms,
)
summary = _summarize_bench_runs(runs)
report = BenchReport(
config_path=str(config_path),
editor_backend="local_llama_builtin",
profile=cfg.ux.profile,
stt_language=stt_lang,
warmup_runs=args.warmup,
measured_runs=args.repeat,
runs=runs,
summary=summary,
)
if args.json:
print(json.dumps(asdict(report), indent=2))
else:
print(
"bench summary: "
f"runs={summary.runs} "
f"total_ms(avg={summary.avg_total_ms:.2f} p50={summary.p50_total_ms:.2f} "
f"p95={summary.p95_total_ms:.2f} min={summary.min_total_ms:.2f} "
f"max={summary.max_total_ms:.2f}) "
f"asr_ms(avg={summary.avg_asr_ms:.2f}) "
f"align_ms(avg={summary.avg_alignment_ms:.2f} applied_avg={summary.avg_alignment_applied:.2f}) "
f"guard_ms(avg={summary.avg_fact_guard_ms:.2f} viol_avg={summary.avg_fact_guard_violations:.2f} "
f"fallback={summary.fallback_runs} rejected={summary.rejected_runs}) "
f"editor_ms(avg={summary.avg_editor_ms:.2f} pass1_avg={summary.avg_editor_pass1_ms:.2f} "
f"pass2_avg={summary.avg_editor_pass2_ms:.2f}) "
f"vocab_ms(avg={summary.avg_vocabulary_ms:.2f})"
)
if args.print_output:
print(last_output)
return 0
def _eval_models_command(args: argparse.Namespace) -> int:
try:
report = run_model_eval(
args.dataset,
args.matrix,
heuristic_dataset_path=(args.heuristic_dataset.strip() or None),
heuristic_weight=args.heuristic_weight,
report_version=args.report_version,
verbose=args.verbose,
)
except Exception as exc:
logging.error("eval-models failed: %s", exc)
return 1
payload = report_to_json(report)
if args.output:
try:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(f"{payload}\n", encoding="utf-8")
except Exception as exc:
logging.error("eval-models failed to write output report: %s", exc)
return 1
logging.info("wrote eval-models report: %s", args.output)
if args.json:
print(payload)
else:
print(format_model_eval_summary(report))
winner_name = str(report.get("winner_recommendation", {}).get("name", "")).strip()
if not winner_name:
return 2
return 0
def _build_heuristic_dataset_command(args: argparse.Namespace) -> int:
try:
summary = build_heuristic_dataset(args.input, args.output)
except Exception as exc:
logging.error("build-heuristic-dataset failed: %s", exc)
return 1
if args.json:
print(json.dumps(summary, indent=2, ensure_ascii=False))
else:
print(
"heuristic dataset built: "
f"raw_rows={summary.get('raw_rows', 0)} "
f"written_rows={summary.get('written_rows', 0)} "
f"generated_word_rows={summary.get('generated_word_rows', 0)} "
f"output={summary.get('output_path', '')}"
)
return 0
def _version_command(_args: argparse.Namespace) -> int:
print(_app_version())
return 0
def _init_command(args: argparse.Namespace) -> int:
config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH
if config_path.exists() and not args.force:
logging.error("init failed: config already exists at %s (use --force to overwrite)", config_path)
return 1
cfg = Config()
save(config_path, cfg)
logging.info("wrote default config to %s", config_path)
return 0
def _run_settings_required_tray(desktop, config_path: Path) -> bool:
reopen_settings = {"value": False}
def open_settings_callback():
reopen_settings["value"] = True
desktop.request_quit()
desktop.run_tray(
lambda: "settings_required",
lambda: None,
on_open_settings=open_settings_callback,
on_show_help=show_help_dialog,
on_show_about=show_about_dialog,
on_open_config=lambda: logging.info("config path: %s", config_path),
)
return reopen_settings["value"]
def _run_settings_until_config_ready(desktop, config_path: Path, initial_cfg: Config) -> Config | None:
draft_cfg = initial_cfg
while True:
result: ConfigUiResult = run_config_ui(
draft_cfg,
desktop,
required=True,
config_path=config_path,
)
if result.saved and result.config is not None:
try:
saved_path = save(config_path, result.config)
except ConfigValidationError as exc:
logging.error("settings apply failed: invalid config field '%s': %s", exc.field, exc.reason)
if exc.example_fix:
logging.error("settings example fix: %s", exc.example_fix)
except Exception as exc:
logging.error("settings save failed: %s", exc)
else:
logging.info("settings saved to %s", saved_path)
return result.config
draft_cfg = result.config
else:
if result.closed_reason:
logging.info("settings were not saved (%s)", result.closed_reason)
if not _run_settings_required_tray(desktop, config_path):
logging.info("settings required mode dismissed by user")
return None
def _load_runtime_config(config_path: Path) -> Config:
if config_path.exists():
return load(str(config_path))
raise FileNotFoundError(str(config_path))
def _run_command(args: argparse.Namespace) -> int:
global _LOCK_HANDLE
config_path = Path(args.config) if args.config else DEFAULT_CONFIG_PATH
config_existed_before_start = config_path.exists()
try:
_LOCK_HANDLE = _lock_single_instance()
except Exception as exc:
logging.error("startup failed: %s", exc)
return 1
try:
desktop = get_desktop_adapter()
except Exception as exc:
_log_support_issue(
logging.ERROR,
"session.x11",
f"startup failed: {exc}",
next_step="log into an X11 session and rerun Aman",
)
return 1
if not config_existed_before_start:
cfg = _run_settings_until_config_ready(desktop, config_path, Config())
if cfg is None:
return 0
else:
try:
cfg = _load_runtime_config(config_path)
except ConfigValidationError as exc:
_log_support_issue(
logging.ERROR,
"config.load",
f"startup failed: invalid config field '{exc.field}': {exc.reason}",
next_step=f"run `{doctor_command(config_path)}` after fixing the config",
)
if exc.example_fix:
logging.error("example fix: %s", exc.example_fix)
return 1
except Exception as exc:
_log_support_issue(
logging.ERROR,
"config.load",
f"startup failed: {exc}",
next_step=f"run `{doctor_command(config_path)}` to inspect config readiness",
)
return 1
try:
validate(cfg)
except ConfigValidationError as exc:
_log_support_issue(
logging.ERROR,
"config.load",
f"startup failed: invalid config field '{exc.field}': {exc.reason}",
next_step=f"run `{doctor_command(config_path)}` after fixing the config",
)
if exc.example_fix:
logging.error("example fix: %s", exc.example_fix)
return 1
except Exception as exc:
_log_support_issue(
logging.ERROR,
"config.load",
f"startup failed: {exc}",
next_step=f"run `{doctor_command(config_path)}` to inspect config readiness",
)
return 1
logging.info("hotkey: %s", cfg.daemon.hotkey)
logging.info(
"config (%s):\n%s",
str(config_path),
json.dumps(redacted_dict(cfg), indent=2),
)
if not config_existed_before_start:
logging.info("first launch settings completed")
logging.info(
"runtime: pid=%s session=%s display=%s wayland_display=%s verbose=%s dry_run=%s",
os.getpid(),
os.getenv("XDG_SESSION_TYPE", ""),
os.getenv("DISPLAY", ""),
os.getenv("WAYLAND_DISPLAY", ""),
args.verbose,
args.dry_run,
)
logging.info("editor backend: local_llama_builtin (%s)", MODEL_PATH)
try:
daemon = Daemon(cfg, desktop, verbose=args.verbose, config_path=config_path)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"startup.readiness",
f"startup failed: {exc}",
next_step=f"run `{self_check_command(config_path)}` and inspect `{journalctl_command()}` if the service still fails",
)
return 1
shutdown_once = threading.Event()
def shutdown(reason: str):
if shutdown_once.is_set():
return
shutdown_once.set()
logging.info("%s, shutting down", reason)
try:
desktop.stop_hotkey_listener()
except Exception as exc:
logging.debug("failed to stop hotkey listener: %s", exc)
if not daemon.shutdown(timeout=5.0):
logging.warning("timed out waiting for idle state during shutdown")
desktop.request_quit()
def handle_signal(_sig, _frame):
threading.Thread(target=shutdown, args=("signal received",), daemon=True).start()
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
def hotkey_callback():
if args.dry_run:
logging.info("hotkey pressed (dry-run)")
return
daemon.toggle()
def reload_config_callback():
nonlocal cfg
try:
new_cfg = load(str(config_path))
except ConfigValidationError as exc:
_log_support_issue(
logging.ERROR,
"config.load",
f"reload failed: invalid config field '{exc.field}': {exc.reason}",
next_step=f"run `{doctor_command(config_path)}` after fixing the config",
)
if exc.example_fix:
logging.error("reload example fix: %s", exc.example_fix)
return
except Exception as exc:
_log_support_issue(
logging.ERROR,
"config.load",
f"reload failed: {exc}",
next_step=f"run `{doctor_command(config_path)}` to inspect config readiness",
)
return
try:
desktop.start_hotkey_listener(new_cfg.daemon.hotkey, hotkey_callback)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"hotkey.parse",
f"reload failed: could not apply hotkey '{new_cfg.daemon.hotkey}': {exc}",
next_step=f"run `{doctor_command(config_path)}` and choose a different hotkey in Settings",
)
return
try:
daemon.apply_config(new_cfg)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"startup.readiness",
f"reload failed: could not apply runtime engines: {exc}",
next_step=f"run `{self_check_command(config_path)}` and then `{verbose_run_command(config_path)}`",
)
return
cfg = new_cfg
logging.info("config reloaded from %s", config_path)
def open_settings_callback():
nonlocal cfg
if daemon.get_state() != State.IDLE:
logging.info("settings UI is available only while idle")
return
result = run_config_ui(
cfg,
desktop,
required=False,
config_path=config_path,
)
if not result.saved or result.config is None:
logging.info("settings closed without changes")
return
try:
save(config_path, result.config)
desktop.start_hotkey_listener(result.config.daemon.hotkey, hotkey_callback)
except ConfigValidationError as exc:
_log_support_issue(
logging.ERROR,
"config.load",
f"settings apply failed: invalid config field '{exc.field}': {exc.reason}",
next_step=f"run `{doctor_command(config_path)}` after fixing the config",
)
if exc.example_fix:
logging.error("settings example fix: %s", exc.example_fix)
return
except Exception as exc:
_log_support_issue(
logging.ERROR,
"hotkey.parse",
f"settings apply failed: {exc}",
next_step=f"run `{doctor_command(config_path)}` and check the configured hotkey",
)
return
try:
daemon.apply_config(result.config)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"startup.readiness",
f"settings apply failed: could not apply runtime engines: {exc}",
next_step=f"run `{self_check_command(config_path)}` and then `{verbose_run_command(config_path)}`",
)
return
cfg = result.config
logging.info("settings applied from tray")
def run_diagnostics_callback():
report = run_self_check(str(config_path))
if report.status == "ok":
logging.info("diagnostics finished (%s, %d checks)", report.status, len(report.checks))
return
flagged = [check for check in report.checks if check.status != "ok"]
logging.warning("diagnostics finished (%s, %d/%d checks need attention)", report.status, len(flagged), len(report.checks))
for check in flagged:
logging.warning("%s", format_diagnostic_line(check))
def open_config_path_callback():
logging.info("config path: %s", config_path)
try:
desktop.start_hotkey_listener(
cfg.daemon.hotkey,
hotkey_callback,
)
except Exception as exc:
_log_support_issue(
logging.ERROR,
"hotkey.parse",
f"hotkey setup failed: {exc}",
next_step=f"run `{doctor_command(config_path)}` and choose a different hotkey if needed",
)
return 1
logging.info("ready")
try:
desktop.run_tray(
daemon.get_state,
lambda: shutdown("quit requested"),
on_open_settings=open_settings_callback,
on_show_help=show_help_dialog,
on_show_about=show_about_dialog,
is_paused_getter=daemon.is_paused,
on_toggle_pause=daemon.toggle_paused,
on_reload_config=reload_config_callback,
on_run_diagnostics=run_diagnostics_callback,
on_open_config=open_config_path_callback,
)
finally:
try:
desktop.stop_hotkey_listener()
except Exception:
pass
daemon.shutdown(timeout=1.0)
return 0
def main(argv: list[str] | None = None) -> int:
args = _parse_cli_args(list(argv) if argv is not None else sys.argv[1:])
if args.command == "run":
_configure_logging(args.verbose)
return _run_command(args)
if args.command == "doctor":
_configure_logging(args.verbose)
return _diagnostic_command(args, run_doctor)
if args.command == "self-check":
_configure_logging(args.verbose)
return _diagnostic_command(args, run_self_check)
if args.command == "bench":
_configure_logging(args.verbose)
return _bench_command(args)
if args.command == "eval-models":
_configure_logging(args.verbose)
return _eval_models_command(args)
if args.command == "build-heuristic-dataset":
_configure_logging(args.verbose)
return _build_heuristic_dataset_command(args)
if args.command == "sync-default-model":
_configure_logging(False)
return _sync_default_model_command(args)
if args.command == "version":
_configure_logging(False)
return _version_command(args)
if args.command == "init":
_configure_logging(False)
return _init_command(args)
raise RuntimeError(f"unsupported command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())