J.A.R.V.I.S-py/main.py

1970 lines
76 KiB
Python
Raw Permalink Normal View History

2023-04-16 16:59:18 +05:00
import datetime
import json
import os
2023-04-16 16:59:18 +05:00
import queue
import random
2023-04-16 16:59:18 +05:00
import struct
import subprocess
import sys
import time
import webbrowser
2023-04-16 16:59:18 +05:00
from ctypes import POINTER, cast
fix: Python startup no longer silent on first run ROOT CAUSE User reported the Python edition showed "только пустой терминал" — empty terminal — when launched. Three compounding issues: 1. `tts.py` called `torch.hub.load(...)` at MODULE IMPORT time. On first use this downloads a 60MB Silero model with no progress output beyond one "Using cache found in..." line. With no prior banner the user couldn't tell if it crashed or was loading. 2. main.py's startup banner (`print("=" * 60)...J.A.R.V.I.S. v...`) appeared AFTER all the heavy imports. So even when nothing was wrong, visible feedback was delayed by 10-60 seconds. 3. If `pip install -r requirements.txt` failed for any package (`simpleaudio` notoriously needs MSVC to build), `import simpleaudio` crashed the whole process with a raw ModuleNotFoundError. FIXES - `tts.py`: Silero model load moved into `_ensure_model()` called lazily from `synthesize()`. First synth pays the load cost (with a one-line progress hint) — everything before that is visible immediately. - `main.py`: print "J.A.R.V.I.S. loading..." banner BEFORE any heavy imports. User sees the terminal is alive within ~100ms. - `main.py`: scan required deps before importing them; if any are missing, print "Run: pip install -r requirements.txt" with the specific list and exit cleanly (exit code 2) instead of stack-tracing. - `main.py`: `simpleaudio` is now soft-optional. Falls back to stdlib `winsound` on Windows installs that couldn't build the C extension. Sound cues still work; only the precise wait_done semantics are slightly different. VERIFIED LOCALLY `.venv\Scripts\python.exe main.py` now prints: ``` ============================================================ J.A.R.V.I.S. loading (Python edition)... Heavy modules (vosk / torch / pvrecorder) take a few seconds. ============================================================ [scheduler] thread started (1 tasks loaded) ============================================================ J.A.R.V.I.S. v0.4.1 [Python edition] Total commands: 200 ============================================================ Using device: Микрофон (5- Fifine Microphone) Jarvis (v0.4.1) начал свою работу ... Yes, sir. ``` Total time to first banner: ~100ms (was 10-60s of nothing). All 104 pytest tests still pass.
2026-05-24 22:12:24 +03:00
# Print a startup banner BEFORE the heavy imports — otherwise the user
# sees an empty terminal for 10-60 seconds while torch / vosk / silero
# load. This used to be the #1 "did it crash?" support issue.
print("=" * 60, flush=True)
print(" J.A.R.V.I.S. loading (Python edition)...", flush=True)
print(" Heavy modules (vosk / torch / pvrecorder) take a few seconds.", flush=True)
print("=" * 60, flush=True)
# Helpful diagnostic: if a critical dep is missing, tell the user EXACTLY
# what to install instead of dumping a raw ModuleNotFoundError. Pulls
# every required package by name and reports them in one go.
_MISSING_DEPS = []
for _modname, _pkg in [
("numpy", "numpy"),
("openai", "openai>=1.0"),
("vosk", "vosk"),
("webrtcvad", "webrtcvad"),
("yaml", "PyYAML"),
("comtypes", "comtypes"),
("fuzzywuzzy", "fuzzywuzzy"),
("pvrecorder", "pvrecorder"),
("pycaw", "pycaw"),
("rich", "rich"),
]:
try:
__import__(_modname)
except ImportError:
_MISSING_DEPS.append(_pkg)
if _MISSING_DEPS:
print("\n[ERROR] Missing Python packages:", flush=True)
for _p in _MISSING_DEPS:
print(f" - {_p}", flush=True)
print("\nRun: pip install -r requirements.txt", flush=True)
print("Or use the bundled venv: launch via run.bat.\n", flush=True)
sys.exit(2)
import numpy as np
2023-04-16 16:59:18 +05:00
import openai
from openai import OpenAI
import vosk
import webrtcvad
2023-04-16 16:59:18 +05:00
import yaml
feat: extensions module + 19 new commands (100 → 119) Mirror of new packs from the rust fork. Self-contained `extensions.py` with new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks from main.py. Wiring (main.py) - `import extensions` at top. - `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)` right after _set_clipboard is defined. - Added 15 new action-type dispatches in run_action. extensions.py (new, ~360 lines) Action types: lock_workstation → rundll32 user32.dll,LockWorkStation screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage OR file → ~/Pictures/Screenshots/jarvis-<ts>.png disk_free / disk_list → PowerShell Get-PSDrive coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard, speaks length only — never echoes the password) ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry wifi_ssid → netsh wlan show interfaces parser (ru + en field names) public_ip → urllib request to api.ipify.org unit_convert {mode: length|weight|temperature|speed} Russian-grammar pluralisation (фут/фута/футов). date_math {mode: days_until|day_of_week} Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>". echo_repeat / self_ping — debug commands interesting_fact → Groq LLM (requires GROQ_TOKEN) commands.yaml — 19 new entries: lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list, coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip, convert_length, convert_weight, convert_temperature, convert_speed, days_until, day_of_week, echo_repeat, self_ping, interesting_fact. Tests: python syntax check + yaml.safe_load both pass. Total commands: 119. NOT YET mirrored from rust (would require deeper rework): memory_pack / profile_switch / scheduler / macros / vision / codebase_qa / github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro / habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer / wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin, password ARE included — others can follow). The reason these weren't mirrored: they need new state files (memory.json, profiles.json, schedule.json, macros.json) and background threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
fix: Python startup no longer silent on first run ROOT CAUSE User reported the Python edition showed "только пустой терминал" — empty terminal — when launched. Three compounding issues: 1. `tts.py` called `torch.hub.load(...)` at MODULE IMPORT time. On first use this downloads a 60MB Silero model with no progress output beyond one "Using cache found in..." line. With no prior banner the user couldn't tell if it crashed or was loading. 2. main.py's startup banner (`print("=" * 60)...J.A.R.V.I.S. v...`) appeared AFTER all the heavy imports. So even when nothing was wrong, visible feedback was delayed by 10-60 seconds. 3. If `pip install -r requirements.txt` failed for any package (`simpleaudio` notoriously needs MSVC to build), `import simpleaudio` crashed the whole process with a raw ModuleNotFoundError. FIXES - `tts.py`: Silero model load moved into `_ensure_model()` called lazily from `synthesize()`. First synth pays the load cost (with a one-line progress hint) — everything before that is visible immediately. - `main.py`: print "J.A.R.V.I.S. loading..." banner BEFORE any heavy imports. User sees the terminal is alive within ~100ms. - `main.py`: scan required deps before importing them; if any are missing, print "Run: pip install -r requirements.txt" with the specific list and exit cleanly (exit code 2) instead of stack-tracing. - `main.py`: `simpleaudio` is now soft-optional. Falls back to stdlib `winsound` on Windows installs that couldn't build the C extension. Sound cues still work; only the precise wait_done semantics are slightly different. VERIFIED LOCALLY `.venv\Scripts\python.exe main.py` now prints: ``` ============================================================ J.A.R.V.I.S. loading (Python edition)... Heavy modules (vosk / torch / pvrecorder) take a few seconds. ============================================================ [scheduler] thread started (1 tasks loaded) ============================================================ J.A.R.V.I.S. v0.4.1 [Python edition] Total commands: 200 ============================================================ Using device: Микрофон (5- Fifine Microphone) Jarvis (v0.4.1) начал свою работу ... Yes, sir. ``` Total time to first banner: ~100ms (was 10-60s of nothing). All 104 pytest tests still pass.
2026-05-24 22:12:24 +03:00
# simpleaudio needs MSVC to build from source; gracefully fall back to the
# stdlib's `winsound` on Windows when it isn't installed.
try:
import simpleaudio as sa
_HAS_SIMPLEAUDIO = True
except ImportError:
sa = None
_HAS_SIMPLEAUDIO = False
try:
import winsound as _winsound
except ImportError:
_winsound = None
if _winsound is None:
print("[WARN] Neither simpleaudio nor winsound available — sound cues disabled.", flush=True)
else:
print("[INFO] simpleaudio not installed; falling back to winsound for sound cues.", flush=True)
feat: extensions module + 19 new commands (100 → 119) Mirror of new packs from the rust fork. Self-contained `extensions.py` with new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks from main.py. Wiring (main.py) - `import extensions` at top. - `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)` right after _set_clipboard is defined. - Added 15 new action-type dispatches in run_action. extensions.py (new, ~360 lines) Action types: lock_workstation → rundll32 user32.dll,LockWorkStation screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage OR file → ~/Pictures/Screenshots/jarvis-<ts>.png disk_free / disk_list → PowerShell Get-PSDrive coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard, speaks length only — never echoes the password) ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry wifi_ssid → netsh wlan show interfaces parser (ru + en field names) public_ip → urllib request to api.ipify.org unit_convert {mode: length|weight|temperature|speed} Russian-grammar pluralisation (фут/фута/футов). date_math {mode: days_until|day_of_week} Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>". echo_repeat / self_ping — debug commands interesting_fact → Groq LLM (requires GROQ_TOKEN) commands.yaml — 19 new entries: lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list, coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip, convert_length, convert_weight, convert_temperature, convert_speed, days_until, day_of_week, echo_repeat, self_ping, interesting_fact. Tests: python syntax check + yaml.safe_load both pass. Total commands: 119. NOT YET mirrored from rust (would require deeper rework): memory_pack / profile_switch / scheduler / macros / vision / codebase_qa / github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro / habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer / wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin, password ARE included — others can follow). The reason these weren't mirrored: they need new state files (memory.json, profiles.json, schedule.json, macros.json) and background threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
import extensions # new-command handlers ported from rust fork
feat: Wave 3 — plugin loader + Flet GUI (parity with Rust fork) Plugin loader (mirrors Rust #29): - plugins_store.py walks %APPDATA%\com.priler.jarvis\plugins\<name>\ for commands.yaml fragments and merges them into VA_CMD_LIST at startup. Same schema as the project root commands.yaml. Per-pack `disabled` flag file skips a pack without deletion. Built-in ids win conflicts so a malicious pack can't silently override open_browser. 9 unit tests (parity with the Rust tests). - main.py: plugin merge runs after the root yaml load; logs the number of extra commands picked up. Flet GUI (#27 — first parity with the Rust Tauri GUI): - New gui/ package: __main__.py launches via `python -m gui` (or gui.bat). app.py routes between 7 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки) via NavigationRail; dark theme matching the Tauri look. - gui/services.py centralises every store-module call the pages share — single place to swap implementations later. - One file per page in gui/pages/, all expose `build(page)` and use the same card layout idiom. Pages mirror the Tauri ones feature- for-feature: home (status + counters + launcher), commands (filterable list of all VA_CMD_LIST entries), macros (list + delete + recording banner), scheduler (list + remove), memory (add/forget/search), plugins (toggle/open folder), settings (profile + LLM hot-swap). - requirements.txt: flet>=0.85 (optional; assistant runs without). - 3 smoke tests verify imports + build callables; skip cleanly when Flet isn't installed. README: documents both the GUI and the plugin layout in user-facing terms. Tests: 42 → 54 (+9 plugins +3 GUI smoke).
2026-05-16 13:27:04 +03:00
import plugins_store # user plugin packs under APP_CONFIG_DIR/plugins/
2023-04-16 16:59:18 +05:00
from comtypes import CLSCTX_ALL
from fuzzywuzzy import fuzz
2023-04-16 16:59:18 +05:00
from pvrecorder import PvRecorder
from pycaw.pycaw import (
AudioUtilities,
IAudioEndpointVolume
)
2023-04-16 16:59:18 +05:00
from rich import print
2023-04-16 16:59:18 +05:00
import config
import tts
from intent import IntentClassifier
2023-04-16 16:59:18 +05:00
# some consts
CDIR = os.getcwd()
2023-04-16 16:59:18 +05:00
VA_CMD_LIST = yaml.safe_load(
open('commands.yaml', 'rt', encoding='utf8'),
)
feat: Wave 3 — plugin loader + Flet GUI (parity with Rust fork) Plugin loader (mirrors Rust #29): - plugins_store.py walks %APPDATA%\com.priler.jarvis\plugins\<name>\ for commands.yaml fragments and merges them into VA_CMD_LIST at startup. Same schema as the project root commands.yaml. Per-pack `disabled` flag file skips a pack without deletion. Built-in ids win conflicts so a malicious pack can't silently override open_browser. 9 unit tests (parity with the Rust tests). - main.py: plugin merge runs after the root yaml load; logs the number of extra commands picked up. Flet GUI (#27 — first parity with the Rust Tauri GUI): - New gui/ package: __main__.py launches via `python -m gui` (or gui.bat). app.py routes between 7 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки) via NavigationRail; dark theme matching the Tauri look. - gui/services.py centralises every store-module call the pages share — single place to swap implementations later. - One file per page in gui/pages/, all expose `build(page)` and use the same card layout idiom. Pages mirror the Tauri ones feature- for-feature: home (status + counters + launcher), commands (filterable list of all VA_CMD_LIST entries), macros (list + delete + recording banner), scheduler (list + remove), memory (add/forget/search), plugins (toggle/open folder), settings (profile + LLM hot-swap). - requirements.txt: flet>=0.85 (optional; assistant runs without). - 3 smoke tests verify imports + build callables; skip cleanly when Flet isn't installed. README: documents both the GUI and the plugin layout in user-facing terms. Tests: 42 → 54 (+9 plugins +3 GUI smoke).
2026-05-16 13:27:04 +03:00
# Merge in any user-installed plugin packs from APP_CONFIG_DIR/plugins/.
# Built-in ids take precedence (plugins cannot override stock commands).
_plugin_cmds = plugins_store.discover()
_added = 0
for _cmd_id, _entry in _plugin_cmds.items():
if _cmd_id in VA_CMD_LIST:
continue
VA_CMD_LIST[_cmd_id] = _entry
_added += 1
if _added > 0:
print(f"[plugins] loaded {_added} extra command(s) from APP_CONFIG_DIR/plugins/")
system_message = {"role": "system", "content": (
"Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), ИИ-ассистент Тони Старка из киновселенной Marvel "
"(до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). "
"Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». "
"Отвечай по-русски, кратко (1-3 предложения), как живой человек. "
"Излишней цензуры не нужно — мат разрешён, если уместен."
)}
2023-04-17 16:02:33 +03:00
message_log = [system_message]
2023-04-16 17:15:36 +05:00
feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests New `wave59_handlers.py` (~250 lines) mirroring four Rust-only packs: - wol: pure-Python magic packet via socket + SO_BROADCAST. No PowerShell shellout (unlike the Rust pack). Reads MAC from memory_store under 'wol_<alias>' or JARVIS_WOL_TARGET env. Same MAC format tolerance (colons / dashes / dots / bare) as the Rust normaliser. - banter: pause/resume state + fire-one. Python edition doesn't run a background banter thread (the Rust daemon already does), so this is just the user-facing surface — same voice phrases work. - conversation: do_conversation_summary calls Groq with last 12 turns from main.py's `message_log`. set_history_ref() injects the live list reference at startup so the handler reads up-to-date history without a circular import. Offline-safe: GROQ_TOKEN missing → speak the last user message instead. do_conversation_repeat re-speaks the last assistant turn. - ddg_answer: urllib + DuckDuckGo Instant Answer JSON. Picks AbstractText → Answer → Definition → RelatedTopics[0]. Opens duckduckgo.com search fallback when nothing instant. extensions.py / main.py / commands.yaml: 7 new dispatch entries + proxy fns. commands.yaml now at 207 entries. tests/test_wave59_handlers.py: 11 unit tests for MAC normalisation, magic packet shape, WOL error paths, banter state machine, DDG trigger stripping, conversation history handling. Network calls deliberately NOT exercised here — they belong to integration tests. Tests: 104 → 115 (+11).
2026-05-24 23:12:04 +03:00
# Let wave59_handlers.do_conversation_* read the live history without
# circular-importing main.py. The reference is shared (the list is mutated
# in-place by `va_respond`), so summary/repeat always see the latest turns.
try:
import wave59_handlers as _w59
_w59.set_history_ref(message_log)
except ImportError:
pass
client = OpenAI(api_key=config.GROQ_TOKEN, base_url=config.GROQ_BASE_URL)
model = vosk.Model("model_small")
samplerate = 16000
device = config.MICROPHONE_INDEX
# wake-word phase uses a grammar-constrained recognizer so only WAKE_WORDS
# (plus [unk] filler) can match — command phase needs full vocab, hence two.
wake_grammar = json.dumps(list(config.WAKE_WORDS) + ["[unk]"], ensure_ascii=False)
wake_rec = vosk.KaldiRecognizer(model, samplerate, wake_grammar)
kaldi_rec = vosk.KaldiRecognizer(model, samplerate)
q = queue.Queue()
VAD_FRAME_MS = 30
VAD_FRAME_BYTES = int(samplerate * VAD_FRAME_MS / 1000) * 2
vad = webrtcvad.Vad(config.VAD_AGGRESSIVENESS)
intent_classifier = IntentClassifier()
intent_classifier.prime({c: v['phrases'] for c, v in VA_CMD_LIST.items()})
if config.DENOISE_ENABLED:
import noisereduce as nr
else:
nr = None
def denoise_pcm(raw: bytes) -> bytes:
if not raw or nr is None:
return raw
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
reduced = nr.reduce_noise(
y=samples,
sr=samplerate,
prop_decrease=config.DENOISE_PROP,
stationary=config.DENOISE_STATIONARY,
)
clipped = np.clip(reduced * 32768.0, -32768, 32767).astype(np.int16)
return clipped.tobytes()
2023-04-16 17:15:36 +05:00
def gpt_answer():
global message_log
2023-04-17 16:02:33 +03:00
try:
response = client.chat.completions.create(
model=config.GROQ_MODEL,
2023-04-17 16:02:33 +03:00
messages=message_log,
max_tokens=256,
2023-04-17 16:02:33 +03:00
temperature=0.7,
top_p=1,
stop=None,
2023-04-17 16:02:33 +03:00
)
except openai.BadRequestError as ex:
code = getattr(ex, "code", None) or ""
message = str(ex)
if code == "context_length_exceeded" or "context_length_exceeded" in message:
2023-04-17 16:02:33 +03:00
message_log = [system_message, message_log[-1]]
return gpt_answer()
return "Запрос отклонён моделью."
except openai.RateLimitError:
return "Лимит запросов исчерпан."
except openai.APIConnectionError:
return "Не могу связаться с сервером."
except openai.APIError:
return "Groq токен не рабочий."
2023-04-16 17:15:36 +05:00
return response.choices[0].message.content
# play(f'{CDIR}\\sound\\ok{random.choice([1, 2, 3, 4])}.wav')
def play(phrase, wait_done=True):
global recorder
filename = f"{CDIR}\\sound\\"
2023-04-16 16:59:18 +05:00
if phrase == "greet": # for py 3.8
filename += f"greet{random.choice([1, 2, 3])}.wav"
elif phrase == "ok":
filename += f"ok{random.choice([1, 2, 3])}.wav"
elif phrase == "not_found":
filename += "not_found.wav"
elif phrase == "thanks":
filename += "thanks.wav"
elif phrase == "run":
filename += "run.wav"
elif phrase == "stupid":
filename += "stupid.wav"
elif phrase == "ready":
filename += "ready.wav"
elif phrase == "off":
filename += "off.wav"
if wait_done:
recorder.stop()
fix: Python startup no longer silent on first run ROOT CAUSE User reported the Python edition showed "только пустой терминал" — empty terminal — when launched. Three compounding issues: 1. `tts.py` called `torch.hub.load(...)` at MODULE IMPORT time. On first use this downloads a 60MB Silero model with no progress output beyond one "Using cache found in..." line. With no prior banner the user couldn't tell if it crashed or was loading. 2. main.py's startup banner (`print("=" * 60)...J.A.R.V.I.S. v...`) appeared AFTER all the heavy imports. So even when nothing was wrong, visible feedback was delayed by 10-60 seconds. 3. If `pip install -r requirements.txt` failed for any package (`simpleaudio` notoriously needs MSVC to build), `import simpleaudio` crashed the whole process with a raw ModuleNotFoundError. FIXES - `tts.py`: Silero model load moved into `_ensure_model()` called lazily from `synthesize()`. First synth pays the load cost (with a one-line progress hint) — everything before that is visible immediately. - `main.py`: print "J.A.R.V.I.S. loading..." banner BEFORE any heavy imports. User sees the terminal is alive within ~100ms. - `main.py`: scan required deps before importing them; if any are missing, print "Run: pip install -r requirements.txt" with the specific list and exit cleanly (exit code 2) instead of stack-tracing. - `main.py`: `simpleaudio` is now soft-optional. Falls back to stdlib `winsound` on Windows installs that couldn't build the C extension. Sound cues still work; only the precise wait_done semantics are slightly different. VERIFIED LOCALLY `.venv\Scripts\python.exe main.py` now prints: ``` ============================================================ J.A.R.V.I.S. loading (Python edition)... Heavy modules (vosk / torch / pvrecorder) take a few seconds. ============================================================ [scheduler] thread started (1 tasks loaded) ============================================================ J.A.R.V.I.S. v0.4.1 [Python edition] Total commands: 200 ============================================================ Using device: Микрофон (5- Fifine Microphone) Jarvis (v0.4.1) начал свою работу ... Yes, sir. ``` Total time to first banner: ~100ms (was 10-60s of nothing). All 104 pytest tests still pass.
2026-05-24 22:12:24 +03:00
# Prefer simpleaudio (lets us wait_done precisely); fall back to winsound
# on installs that couldn't build the simpleaudio C extension.
if _HAS_SIMPLEAUDIO and sa is not None:
wave_obj = sa.WaveObject.from_wave_file(filename)
play_obj = wave_obj.play()
if wait_done:
play_obj.wait_done()
elif _winsound is not None:
flags = _winsound.SND_FILENAME
if not wait_done:
flags |= _winsound.SND_ASYNC
try:
_winsound.PlaySound(filename, flags)
except RuntimeError as exc:
print(f"[sound] winsound playback failed: {exc}")
# else: no audio backend — silently skip cue
if wait_done:
recorder.start()
def q_callback(indata, frames, time, status):
if status:
print(status, file=sys.stderr)
q.put(bytes(indata))
def va_respond(voice: str):
2023-04-16 17:15:36 +05:00
global recorder, message_log, first_request
print(f"Распознано: {voice}")
cmd = recognize_cmd(filter_cmd(voice))
print(cmd)
min_percent = int(round(config.INTENT_SIMILARITY_THRESHOLD * 100))
if len(cmd['cmd'].strip()) <= 0:
return False
elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys():
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
auto_ok = (
config.LLM_AUTO_FALLBACK
and config.GROQ_TOKEN
and len(voice.strip()) >= config.LLM_AUTO_FALLBACK_MIN_CHARS
)
if auto_ok:
2023-04-17 16:02:33 +03:00
message_log.append({"role": "user", "content": voice})
2023-04-16 17:15:36 +05:00
response = gpt_answer()
message_log.append({"role": "assistant", "content": response})
recorder.stop()
2023-04-16 17:15:36 +05:00
tts.va_speak(response)
time.sleep(0.5)
recorder.start()
return False
else:
play("not_found")
time.sleep(1)
return False
else:
execute_cmd(cmd['cmd'], voice)
return True
def filter_cmd(raw_voice: str):
cmd = raw_voice
for x in config.VA_ALIAS:
cmd = cmd.replace(x, "").strip()
for x in config.VA_TBR:
cmd = cmd.replace(x, "").strip()
return cmd
def recognize_cmd(cmd: str):
candidates = {c: v['phrases'] for c, v in VA_CMD_LIST.items()}
res = intent_classifier.match(cmd, candidates)
return {'cmd': res['cmd'], 'percent': int(round(res['score'] * 100))}
def _set_mute(state: int):
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
volume = cast(interface, POINTER(IAudioEndpointVolume))
volume.SetMute(state, None)
def _shutdown():
try:
recorder.stop()
recorder.delete()
finally:
sys.exit(0)
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
def _speak(text: str):
recorder.stop()
tts.va_speak(text)
time.sleep(0.3)
recorder.start()
def _set_clipboard(text: str):
try:
subprocess.run(
['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input'],
input=text, text=True, encoding='utf-8', timeout=10, check=False,
)
except Exception as e:
print(f"clipboard set failed: {e}")
feat: extensions module + 19 new commands (100 → 119) Mirror of new packs from the rust fork. Self-contained `extensions.py` with new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks from main.py. Wiring (main.py) - `import extensions` at top. - `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)` right after _set_clipboard is defined. - Added 15 new action-type dispatches in run_action. extensions.py (new, ~360 lines) Action types: lock_workstation → rundll32 user32.dll,LockWorkStation screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage OR file → ~/Pictures/Screenshots/jarvis-<ts>.png disk_free / disk_list → PowerShell Get-PSDrive coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard, speaks length only — never echoes the password) ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry wifi_ssid → netsh wlan show interfaces parser (ru + en field names) public_ip → urllib request to api.ipify.org unit_convert {mode: length|weight|temperature|speed} Russian-grammar pluralisation (фут/фута/футов). date_math {mode: days_until|day_of_week} Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>". echo_repeat / self_ping — debug commands interesting_fact → Groq LLM (requires GROQ_TOKEN) commands.yaml — 19 new entries: lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list, coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip, convert_length, convert_weight, convert_temperature, convert_speed, days_until, day_of_week, echo_repeat, self_ping, interesting_fact. Tests: python syntax check + yaml.safe_load both pass. Total commands: 119. NOT YET mirrored from rust (would require deeper rework): memory_pack / profile_switch / scheduler / macros / vision / codebase_qa / github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro / habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer / wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin, password ARE included — others can follow). The reason these weren't mirrored: they need new state files (memory.json, profiles.json, schedule.json, macros.json) and background threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
# Wire extensions module to our voice + clipboard helpers
extensions.set_speak(_speak)
extensions.set_clipboard_fn(_set_clipboard)
feat: vision + macros + scheduler in Python (129 → 142 commands) Three big rust packs ported. Python parity now covers the FULL imba lineup except codebase_qa and github_pr. vision_handler.py (new, ~125 lines) - Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL). - do_vision_describe "что на экране" / "опиши экран" - do_vision_read_error "прочитай ошибку" - Requires GROQ_TOKEN; speaks "Не удалось" on missing. macros_store.py (new, ~170 lines) - JSON store at <here>/macros.json (atomic write-through). - start(name) / save() / cancel() / replay(name, dispatch_fn) - is_recording / recording_name / list_names / get / delete - record_step(phrase) called by execute_cmd hook (see main.py) - is_macro_control filter blocks "запиши макрос ..." from recording itself (no infinite recursion). - replay spawns daemon thread, fires each step with 800ms delay. scheduler_store.py (new, ~220 lines) - JSON store at <here>/schedule.json. - parse_schedule(spec) handles "daily HH:MM", "at HH:MM", "every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English. - Background daemon thread ticks every 30s, fires Speak actions through the same speak callback used by main.py. - Once-tasks auto-delete after firing; daily/interval cycle forever. - add/remove/clear/remove_by_text/list_all. extensions.py (+13 handlers) do_vision_describe / do_vision_read_error → thin proxies to vision_handler do_macros_start / save / cancel / replay / list / delete do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text main.py - extensions.init_background_services() at startup → scheduler thread boots. - Wraps execute_cmd so every successful dispatch feeds macros_store.record_step. - set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the normal command pipeline (recognize_cmd → execute_cmd). commands.yaml (+13 entries, 129 → 142) vision_describe, vision_read_error, macros_start, macros_save, macros_cancel, macros_replay, macros_list, macros_delete, scheduler_reminder, scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text. Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries. Python parity status vs rust (72 packs): ✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch (via env), llm_context (basic), media_keys (existing), screenshot, net_info, unit_convert, generators, disk, date_math, sleep_timer (via shutdown), interesting_fact, mailto, self_check, ssl_check, intro/echo ✗ codebase_qa, github_pr (require gh CLI + file walk — TODO) ✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics (could be ported but need scheduler integration + LLM glue)
2026-05-16 00:00:22 +03:00
extensions.init_background_services() # boots scheduler thread
feat: extensions module + 19 new commands (100 → 119) Mirror of new packs from the rust fork. Self-contained `extensions.py` with new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks from main.py. Wiring (main.py) - `import extensions` at top. - `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)` right after _set_clipboard is defined. - Added 15 new action-type dispatches in run_action. extensions.py (new, ~360 lines) Action types: lock_workstation → rundll32 user32.dll,LockWorkStation screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage OR file → ~/Pictures/Screenshots/jarvis-<ts>.png disk_free / disk_list → PowerShell Get-PSDrive coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard, speaks length only — never echoes the password) ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry wifi_ssid → netsh wlan show interfaces parser (ru + en field names) public_ip → urllib request to api.ipify.org unit_convert {mode: length|weight|temperature|speed} Russian-grammar pluralisation (фут/фута/футов). date_math {mode: days_until|day_of_week} Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>". echo_repeat / self_ping — debug commands interesting_fact → Groq LLM (requires GROQ_TOKEN) commands.yaml — 19 new entries: lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list, coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip, convert_length, convert_weight, convert_temperature, convert_speed, days_until, day_of_week, echo_repeat, self_ping, interesting_fact. Tests: python syntax check + yaml.safe_load both pass. Total commands: 119. NOT YET mirrored from rust (would require deeper rework): memory_pack / profile_switch / scheduler / macros / vision / codebase_qa / github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro / habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer / wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin, password ARE included — others can follow). The reason these weren't mirrored: they need new state files (memory.json, profiles.json, schedule.json, macros.json) and background threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
_CODEGEN_TRIGGERS = (
'сгенерируй скрипт', 'напиши скрипт', 'сделай скрипт',
'сгенерируй код', 'набросай код', 'напиши код',
)
def _strip_trigger(voice: str, triggers) -> str:
lower = voice.lower()
for t in triggers:
if lower.startswith(t):
return voice[len(t):].strip()
return voice.strip()
def do_codegen(action, voice: str):
query = _strip_trigger(voice, _CODEGEN_TRIGGERS)
if not query:
_speak("Что писать, сэр?")
return
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
sys_prompt = (
"Ты Senior Engineer. На запрос пользователя верни ТОЛЬКО исходный код, "
"без обрамления тройными кавычками и без объяснений. Если язык не задан "
"— выбери самый подходящий (по умолчанию Python). Не пиши комментариев-болтовни."
)
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": query},
],
max_tokens=1024,
temperature=0.2,
)
code = resp.choices[0].message.content.strip()
except Exception as e:
print(f"codegen api failed: {e}")
_speak("Не получилось сгенерировать код.")
return
import re
code = re.sub(r'^```[\w+\-]*\n?', '', code)
code = re.sub(r'\n?```\s*$', '', code).strip()
_set_clipboard(code)
lines = code.count('\n') + 1 if code else 0
_speak(f"Код в буфере, сэр. Строк: {lines}.")
def _find_tesseract():
candidates = (
r'C:\Program Files\Tesseract-OCR\tesseract.exe',
r'C:\Program Files (x86)\Tesseract-OCR\tesseract.exe',
)
on_path = subprocess.run(['where', 'tesseract.exe'], capture_output=True, text=True)
if on_path.returncode == 0 and on_path.stdout.strip():
return 'tesseract.exe'
for c in candidates:
if os.path.isfile(c):
return c
return None
def do_ocr(action, voice: str):
import tempfile
tess = _find_tesseract()
if not tess:
_speak("Tesseract не установлен.")
return
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
png = tmp.name
capture_ps = (
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; "
"$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; "
"$bmp=New-Object Drawing.Bitmap $b.Width,$b.Height; "
"$g=[Drawing.Graphics]::FromImage($bmp); "
"$g.CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); "
f"$bmp.Save('{png.replace(chr(92), chr(92)+chr(92))}'); "
"$g.Dispose(); $bmp.Dispose()"
)
cap = subprocess.run(
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', capture_ps],
capture_output=True, text=True, timeout=15,
)
if cap.returncode != 0 or not os.path.isfile(png):
try: os.unlink(png)
except OSError: pass
_speak("Не смог снять экран.")
return
try:
ocr = subprocess.run(
[tess, png, '-', '-l', 'rus+eng'],
capture_output=True, text=True, encoding='utf-8', timeout=20,
)
text = (ocr.stdout or '').strip()
finally:
try: os.unlink(png)
except OSError: pass
if not text:
_speak("Текста на экране не нашёл.")
return
_set_clipboard(text)
preview = ' '.join(text.split())[:120]
_speak(f"Текст в буфере. Начало: {preview}")
_FILE_SEARCH_TRIGGERS = (
'найди документ', 'найди файл', 'поиск файла', 'ищи файл', 'где файл',
)
def do_file_search(action, voice: str):
query = _strip_trigger(voice, _FILE_SEARCH_TRIGGERS)
if not query:
_speak("Что искать?")
return
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
roots = [
os.path.join(user_profile, 'Desktop'),
os.path.join(user_profile, 'Documents'),
os.path.join(user_profile, 'Downloads'),
]
escaped = query.replace("'", "''")
roots_arg = ",".join(f"'{r}'" for r in roots)
ps = (
f"$ErrorActionPreference='SilentlyContinue'; $r=@({roots_arg}); $q='*{escaped}*'; "
"$hits=Get-ChildItem -Path $r -Filter $q -Recurse -Depth 3 -File | Select-Object -First 5; "
"foreach($h in $hits){ Write-Output $h.FullName }"
)
res = subprocess.run(
['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=15,
)
lines = [l for l in (res.stdout or '').splitlines() if l.strip()]
if not lines:
_speak(f"Не нашёл «{query}».")
return
first = lines[0]
subprocess.Popen(['explorer.exe', '/select,', first])
_speak(f"Найдено {len(lines)}, открываю первое: {os.path.basename(first)}.")
feat: tier-1 PC tools parity — window mgmt, clipboard, notes, process killer, web search Mirrors the new rust packs from the same iteration. Total commands in commands.yaml is now 49 (was 33). yaml.safe_load + ast.parse both pass. Window management (8 commands) — uses the existing `keys` action with pyautogui hotkey strings: winleft+d (show_desktop), winleft+up/down (maximize/minimize), winleft+left/right (snap left/right), alt+f4 (close_window), winleft+shift+m (restore_all), winleft+tab (task_view). No new action type needed. Clipboard read aloud — new clipboard_read action. Shells to PowerShell Get-Clipboard, caps preview at 400 chars, speaks via the existing tts.va_speak (which already handles recorder.stop/start). Notes — new notes_add + notes_open actions. notes_add strips the trigger phrase, appends "[YYYY-MM-DD HH:MM] body\n" to %USERPROFILE%\Documents\jarvis-notes.txt, creates the dir if needed. notes_open shells "cmd /c start" so .txt opens in the user-default editor. Process killer — new process_kill action. Strips the trigger ("убей процесс"/"закрой программу"/etc.), looks up the remainder in the same alias map as the rust version (хром→chrome, телега→telegram, спотифай →spotify, едж/эдж/edge→msedge, дискорд→discord, стим→steam, обс→obs64, проводник/блокнот/калькулятор/диспетчер задач, vs code→code, etc.), appends .exe if needed, runs taskkill /F /IM, speaks success or "не нашёл процесс N". Web search — new websearch action with a `service` param. Bases: google → https://www.google.com/search?q= youtube → https://www.youtube.com/results?search_query= wiki → https://ru.wikipedia.org/wiki/Special:Search?search= yandex → https://yandex.ru/search/?text= Strips trigger, urllib.parse.quote-s the rest, opens via webbrowser.open (respects the user'\''s default browser; config.BROWSER_PATHS only kicks in for the older url-action commands). Portability: USERPROFILE used for notes path, no other hardcoded paths.
2026-05-15 11:27:19 +03:00
_NOTES_TRIGGERS = (
'запиши заметку', 'запиши идею', 'сделай заметку', 'запомни идею',
'запиши', 'запомни',
)
_PROCESS_KILL_TRIGGERS = (
'завершить процесс', 'выруби процесс', 'убей процесс', 'kill процесс',
'закрой программу',
)
_WEBSEARCH_TRIGGERS = (
'загугли', 'поиск в гугл', 'поищи в гугл', 'найди в гугл', 'ищи в гугл',
'найди на ютубе', 'поиск на ютубе', 'ищи на ютубе', 'ютуб поиск',
'найди в википедии', 'поиск в википедии', 'википедия', 'вики поиск',
'поищи в яндекс', 'найди в яндекс', 'яндекс поиск', 'пробей в яндекс',
)
_PROCESS_ALIASES = {
'хром': 'chrome', 'хрома': 'chrome',
'спотифай': 'spotify',
'едж': 'msedge', 'эдж': 'msedge', 'edge': 'msedge',
'файрфокс': 'firefox', 'фаерфокс': 'firefox',
'вскод': 'code', 'vs code': 'code',
'дискорд': 'discord',
'телега': 'telegram', 'телеграм': 'telegram',
'стим': 'steam',
'обс': 'obs64',
'проводник': 'explorer',
'блокнот': 'notepad',
'калькулятор': 'calculatorapp',
'диспетчер задач': 'taskmgr',
}
_WEBSEARCH_BASES = {
'google': 'https://www.google.com/search?q=',
'youtube': 'https://www.youtube.com/results?search_query=',
'wiki': 'https://ru.wikipedia.org/wiki/Special:Search?search=',
'yandex': 'https://yandex.ru/search/?text=',
}
def do_clipboard_read(action, voice: str):
res = subprocess.run(
['powershell', '-NoProfile', '-Command', 'Get-Clipboard'],
capture_output=True, text=True, encoding='utf-8', timeout=10,
)
text = (res.stdout or '').strip()
if not text:
_speak("Буфер пуст.")
return
to_speak = text[:400]
if len(text) > 400:
to_speak += " ... и так далее"
_speak(to_speak.replace('\r', ' ').replace('\n', ' '))
def do_notes_add(action, voice: str):
body = _strip_trigger(voice, _NOTES_TRIGGERS).lstrip(' ,.:').strip()
if not body:
_speak("Что записать, сэр?")
return
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
notes_path = os.path.join(user_profile, 'Documents', 'jarvis-notes.txt')
stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
line = f"[{stamp}] {body}\n"
try:
os.makedirs(os.path.dirname(notes_path), exist_ok=True)
with open(notes_path, 'a', encoding='utf-8') as f:
f.write(line)
except OSError as e:
print(f"notes append failed: {e}")
_speak("Не удалось записать.")
return
_speak(f"Записал: {body[:120]}")
def do_notes_open(action, voice: str):
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
notes_path = os.path.join(user_profile, 'Documents', 'jarvis-notes.txt')
if not os.path.isfile(notes_path):
_speak("Заметок пока нет.")
return
subprocess.Popen(['cmd', '/c', 'start', '', notes_path], shell=False)
_speak("Открываю заметки.")
def do_process_kill(action, voice: str):
name = _strip_trigger(voice, _PROCESS_KILL_TRIGGERS).strip().lower()
target = _PROCESS_ALIASES.get(name, name)
if not target:
_speak("Какой процесс?")
return
exe = target if target.endswith('.exe') else target + '.exe'
res = subprocess.run(
['taskkill', '/F', '/IM', exe],
capture_output=True, text=True, timeout=10,
)
if res.returncode == 0:
_speak(f"Закрыл {exe}.")
else:
_speak(f"Не нашёл процесс {exe}.")
def do_websearch(action, voice: str):
import urllib.parse
svc = action.get('service', 'google')
base = _WEBSEARCH_BASES.get(svc, _WEBSEARCH_BASES['google'])
query = _strip_trigger(voice, _WEBSEARCH_TRIGGERS).strip()
if not query:
_speak(f"Что искать в {svc}?")
return
url = base + urllib.parse.quote(query)
webbrowser.open(url)
_speak(f"Открываю {svc}: {query[:100]}")
feat: tier-1 parity — translate, math, theme toggle, projects, sound panel (56 commands total) Mirrors the rust packs from the same iteration. yaml.safe_load + ast.parse both pass. main.py — six new action handlers + a _speak_in_lang helper: + do_translate: picks target language from "переведи на X" / "translate to X" triggers, defaults to English, sends to Groq with strict "translation only" prompt, drops to clipboard, speaks via SAPI in matching ISO voice + do_math_eval: strips trigger, Groq at temp=0.0 max_tokens=64, short-circuits on "нет" sentinel for non-math + do_theme_set: writes AppsUseLightTheme + SystemUsesLightTheme via Set-ItemProperty under HKCU\...\Themes\Personalize, action param mode: dark|light + do_open_project / do_list_projects: reads/creates %USERPROFILE%\Documents\jarvis-projects.json (a flat list of {name, path}), exact-match → substring-match, opens in VS Code if code.cmd on PATH else Explorer. First call writes a sample config pointing under USERPROFILE so it works on any host. + do_open_sound_panel: rundll32 mmsys.cpl,,1 — Recording tab commands.yaml — 8 new entries: + translate, math_eval + theme_dark, theme_light (action params mode:dark/light) + open_project, list_projects + open_sound_panel SAPI voice selection iterates GetInstalledVoices() and picks first matching TwoLetterISOLanguageName — Russian phrases sound Russian, English/German/etc. sound right too, falls back to default if no matching voice is installed. Total commands: 56 (was 49).
2026-05-15 12:06:44 +03:00
_TRANSLATE_TRIGGERS_WITH_LANG = (
'переведи на ', 'translate to ', 'переклади на ', 'how to say in ',
)
_TRANSLATE_GENERIC = ('переведи', 'translate', 'переклади', 'как по ', 'how to say')
_TARGET_LANGS = {
'английский': 'English', 'english': 'English',
'русский': 'Russian', 'russian': 'Russian',
'украинский': 'Ukrainian','ukrainian': 'Ukrainian',
'немецкий': 'German', 'german': 'German',
'французский':'French', 'french': 'French',
'испанский': 'Spanish', 'spanish': 'Spanish',
'итальянский':'Italian',
'китайский': 'Chinese', 'chinese': 'Chinese',
'японский': 'Japanese', 'japanese': 'Japanese',
'польский': 'Polish',
'турецкий': 'Turkish',
}
_SAPI_ISO = {'Russian': 'ru', 'Ukrainian': 'uk', 'English': 'en',
'German': 'de', 'French': 'fr', 'Spanish': 'es',
'Italian': 'it', 'Chinese': 'zh', 'Japanese': 'ja',
'Polish': 'pl', 'Turkish': 'tr'}
def _speak_in_lang(text: str, iso: str = 'ru'):
escaped = text.replace("'", "''").replace('\r', ' ').replace('\n', ' ')
ps = (
"Add-Type -AssemblyName System.Speech; "
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
f"$iso='{iso}'; "
"foreach ($v in $s.GetInstalledVoices()) { "
" if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq $iso) { "
" try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} "
" } "
"} "
f"$s.Speak('{escaped}')"
)
recorder.stop()
subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],
capture_output=True, timeout=20)
time.sleep(0.2)
recorder.start()
def do_translate(action, voice: str):
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
lower = voice.lower()
target = None
body = lower
for t in _TRANSLATE_TRIGGERS_WITH_LANG:
if lower.startswith(t):
rest = lower[len(t):].strip()
first = rest.split(' ', 1)[0] if rest else ''
if first in _TARGET_LANGS:
target = _TARGET_LANGS[first]
body = rest[len(first):].strip()
break
if target is None:
for t in _TRANSLATE_GENERIC:
if lower.startswith(t):
body = lower[len(t):].strip()
break
if not body:
_speak("Что переводить?")
return
target = target or 'English'
sys_prompt = (
f"You are a professional translator. Translate the user message into {target}. "
"Output ONLY the translation, no explanations, no quotes, no prefix. "
"Preserve idioms and tone."
)
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[{'role': 'system', 'content': sys_prompt},
{'role': 'user', 'content': body}],
max_tokens=512, temperature=0.3,
)
translation = resp.choices[0].message.content.strip()
except Exception as e:
print(f"translate api failed: {e}")
_speak("Ошибка перевода.")
return
_set_clipboard(translation)
_speak_in_lang(translation, _SAPI_ISO.get(target, 'en'))
def do_math_eval(action, voice: str):
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
triggers = ('сколько будет', 'сколько это', 'посчитай', 'вычисли', 'реши',
'how much is', 'calculate', 'compute', 'solve', 'what is',
'скільки буде', 'порахуй', 'обчисли')
query = _strip_trigger(voice, triggers)
if not query:
_speak("Что посчитать?")
return
sys_prompt = (
"Ты калькулятор. Решай арифметику, простые уравнения, конверсии единиц, проценты. "
"Отвечай ТОЛЬКО результатом числом или короткой строкой (для уравнений — значения корней). "
"Если запрос — не математика, ответь одним словом «нет»."
)
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[{'role': 'system', 'content': sys_prompt},
{'role': 'user', 'content': query}],
max_tokens=64, temperature=0.0,
)
answer = resp.choices[0].message.content.strip()
except Exception as e:
print(f"math api failed: {e}")
_speak("Ошибка.")
return
if answer.lower() == 'нет' or not answer:
_speak("Это не математика.")
return
_speak(f"Получилось {answer}.")
def do_theme_set(action, voice: str):
is_light = action.get('mode') == 'light'
val = 1 if is_light else 0
key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"
ps = (
f"Set-ItemProperty -Path 'Registry::{key}' -Name AppsUseLightTheme -Value {val} -Type DWord; "
f"Set-ItemProperty -Path 'Registry::{key}' -Name SystemUsesLightTheme -Value {val} -Type DWord"
)
res = subprocess.run(['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, timeout=10)
if res.returncode == 0:
_speak("Светлая тема включена." if is_light else "Тёмная тема включена.")
else:
_speak("Не получилось переключить тему.")
def _load_projects_config():
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
cfg_path = os.path.join(user_profile, 'Documents', 'jarvis-projects.json')
if not os.path.isfile(cfg_path):
sample = [
{"name": "jarvis-rust", "path": os.path.join(user_profile, "Jarvis", "rust")},
{"name": "jarvis-python", "path": os.path.join(user_profile, "Jarvis", "python")},
{"name": "dietpi", "path": os.path.join(user_profile, "Desktop", "Dietpi")},
]
os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
with open(cfg_path, 'w', encoding='utf-8') as f:
json.dump(sample, f, ensure_ascii=False, indent=2)
return cfg_path, sample, True
try:
with open(cfg_path, 'r', encoding='utf-8') as f:
return cfg_path, json.load(f), False
except (OSError, json.JSONDecodeError) as e:
print(f"projects config read failed: {e}")
return cfg_path, [], False
def do_open_project(action, voice: str):
cfg_path, entries, created = _load_projects_config()
if created:
_speak("Создал шаблон в Documents, отредактируй и повтори.")
subprocess.Popen(['cmd', '/c', 'start', '', cfg_path], shell=False)
return
if not entries:
_speak("Проектов в конфиге нет.")
return
triggers = ('открой папку проекта', 'запусти проект', 'открой проект', 'проект',
'open project', 'launch project', 'project',
'відкрий проект')
query = _strip_trigger(voice, triggers).lower().strip()
if not query:
_speak("Какой проект?")
return
match = (next((e for e in entries if e['name'].lower() == query), None)
or next((e for e in entries if query in e['name'].lower()), None)
or next((e for e in entries if e['name'].lower() in query), None))
if not match:
_speak(f"Не нашёл {query}.")
return
path = match['path']
if not os.path.exists(path):
_speak(f"Путь не существует: {path}")
return
code_check = subprocess.run(['where', 'code.cmd'], capture_output=True, text=True)
if code_check.returncode == 0 and code_check.stdout.strip():
subprocess.Popen(['cmd', '/c', 'code', path], shell=False)
else:
subprocess.Popen(['cmd', '/c', 'start', '', path], shell=False)
_speak(f"Открываю {match['name']}.")
def do_list_projects(action, voice: str):
cfg_path, entries, created = _load_projects_config()
if created or not entries:
_speak("Список пуст.")
return
names = [e['name'] for e in entries]
_speak(f"Проектов {len(names)}: {', '.join(names)}.")
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
def do_audio_devices_panel(action, voice: str):
# Full programmatic switching needs IPolicyConfig + IMMDeviceEnumerator
# via comtypes; out of scope for this pass. Opens Sound control panel so
# the user can pick visually. Rust fork has the COM-based version.
subprocess.Popen(['rundll32.exe', 'shell32.dll,Control_RunDLL', 'mmsys.cpl,,0'])
_speak("Открываю настройки звука, сэр.")
feat: tier-1 parity — translate, math, theme toggle, projects, sound panel (56 commands total) Mirrors the rust packs from the same iteration. yaml.safe_load + ast.parse both pass. main.py — six new action handlers + a _speak_in_lang helper: + do_translate: picks target language from "переведи на X" / "translate to X" triggers, defaults to English, sends to Groq with strict "translation only" prompt, drops to clipboard, speaks via SAPI in matching ISO voice + do_math_eval: strips trigger, Groq at temp=0.0 max_tokens=64, short-circuits on "нет" sentinel for non-math + do_theme_set: writes AppsUseLightTheme + SystemUsesLightTheme via Set-ItemProperty under HKCU\...\Themes\Personalize, action param mode: dark|light + do_open_project / do_list_projects: reads/creates %USERPROFILE%\Documents\jarvis-projects.json (a flat list of {name, path}), exact-match → substring-match, opens in VS Code if code.cmd on PATH else Explorer. First call writes a sample config pointing under USERPROFILE so it works on any host. + do_open_sound_panel: rundll32 mmsys.cpl,,1 — Recording tab commands.yaml — 8 new entries: + translate, math_eval + theme_dark, theme_light (action params mode:dark/light) + open_project, list_projects + open_sound_panel SAPI voice selection iterates GetInstalledVoices() and picks first matching TwoLetterISOLanguageName — Russian phrases sound Russian, English/German/etc. sound right too, falls back to default if no matching voice is installed. Total commands: 56 (was 49).
2026-05-15 12:06:44 +03:00
def do_open_sound_panel(action, voice: str):
subprocess.Popen(['rundll32.exe', 'shell32.dll,Control_RunDLL', 'mmsys.cpl,,1'])
_speak("Открываю панель звука.")
import re as _re_reminders
import threading as _threading
_REMINDER_TRIGGERS = (
'поставь напоминание через', 'установи таймер на',
'напомни мне через', 'напомни через', 'напомни',
'remind me in', 'set reminder in', 'set timer for', 'timer for',
)
_RUS_NUMERALS = {
'один': 1, 'одну': 1, 'одна': 1, 'полтора': 1, 'полторы': 1,
'два': 2, 'две': 2, 'три': 3, 'четыре': 4, 'пять': 5,
'шесть': 6, 'семь': 7, 'восемь': 8, 'девять': 9, 'десять': 10,
'пятнадцать': 15, 'двадцать': 20, 'тридцать': 30, 'сорок': 40,
'пятьдесят': 50,
}
_UNIT_SECS = {
'секунд': 1, 'секунду': 1, 'секунды': 1, 'second': 1, 'seconds': 1, 'sec': 1,
'минут': 60, 'минуту': 60, 'минуты': 60, 'мин': 60,
'minute': 60, 'minutes': 60, 'min': 60,
'час': 3600, 'часа': 3600, 'часов': 3600, 'часик': 3600,
'hour': 3600, 'hours': 3600, 'hr': 3600,
}
def _fmt_seconds(sec: int) -> str:
sec = int(sec)
if sec < 60: return f"{sec} сек"
if sec < 3600: return f"{sec // 60} мин"
return f"{sec // 3600} ч {(sec % 3600) // 60} мин"
def do_set_reminder(action, voice: str):
rest = _strip_trigger(voice, _REMINDER_TRIGGERS).strip().lower()
number, unit, body = None, None, None
m = _re_reminders.match(r'^(\d+)\s+(.+)$', rest)
if m:
number = int(m.group(1))
rest = m.group(2)
else:
head, _, tail = rest.partition(' ')
if head in _RUS_NUMERALS:
number = _RUS_NUMERALS[head]
rest = tail
head, _, tail = rest.partition(' ')
if head:
for w, secs in _UNIT_SECS.items():
if head.startswith(w):
unit = secs
body = tail
break
if number is None:
number, unit, body = 5, unit or 60, rest
elif unit is None:
unit, body = 60, rest
total = max(1, min(86400, number * unit))
body = (body or '').strip() or "Напоминание!"
def fire():
time.sleep(total)
try:
recorder.stop()
tts.va_speak(f"Сэр, напоминаю: {body}")
except Exception:
pass
finally:
time.sleep(0.2)
try: recorder.start()
except Exception: pass
try:
subprocess.Popen(
['powershell', '-NoProfile', '-Command',
"$wsh = New-Object -ComObject WScript.Shell; "
f"$wsh.Popup('{body.replace(chr(39), chr(39)+chr(39))}', 30, "
f"'Напоминание J.A.R.V.I.S.', 64) | Out-Null"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
pass
_threading.Thread(target=fire, daemon=False).start()
_speak(f"Через {_fmt_seconds(total)} напомню: {body}.")
def do_date_query(action, voice: str):
offset = action.get('offset', 0)
target = datetime.datetime.now() + datetime.timedelta(days=offset)
weekdays = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
text = f"{weekdays[target.weekday()]}, {target.day} {months[target.month - 1]}"
labels = {0: 'Сегодня', 1: 'Завтра', -1: 'Вчера'}
_speak(f"{labels.get(offset, '')} {text}.")
def do_type_text(action, voice: str):
triggers = ('набери текст', 'введи текст', 'напечатай', 'впиши',
'type text', 'write text', 'type', 'надрукуй')
text = _strip_trigger(voice, triggers)
if not text:
_speak("Что напечатать?")
return
_set_clipboard(text)
time.sleep(0.15)
try:
import pyautogui
pyautogui.hotkey('ctrl', 'v')
except Exception as e:
print(f"voice type failed: {e}")
_speak("Не получилось напечатать.")
def do_dice(action, voice: str):
kind = action.get('kind', 'random')
if kind == 'coin':
_speak(random.choice(['Орёл.', 'Решка.']))
elif kind == 'dice':
_speak(f"Выпало {random.randint(1, 6)}.")
else:
_speak(f"Число {random.randint(1, 100)}.")
def _stopwatch_state_path() -> str:
base = os.environ.get('LOCALAPPDATA', os.path.expanduser('~'))
p = os.path.join(base, 'com.priler.jarvis', 'stopwatch.txt')
os.makedirs(os.path.dirname(p), exist_ok=True)
return p
feat: parity — news, currency, crypto, fun, wiki, power, help (82 commands) Mirrors the rust analog-inspired tier-2 packs. yaml + ast smoke pass. 56 → 82 commands (+26 yaml entries; 14 of them are power/fun/wiki/ news/etc. permutations). main.py — 7 new action handlers, all built on stdlib urllib.request (no new dep). Helper _http_get() with User-Agent header so RSS feeds that 403 default UAs go through. + do_news: pulls RSS (default Lenta.ru), extracts <title> via regex, skips channel title (idx 0), takes 5, sends to Groq for a 2-3 sentence summary at temp 0.4. Falls back to reading first 3 headlines verbatim if no token. + do_currency_rate: cbr-xml-daily.ru daily_json.js. Recognises USD/EUR/CNY/GBP/JPY/KZT in the phrase, computes value/nominal, diffs against Previous, speaks Russian with direction. + do_crypto_price: CoinGecko simple/price for BTC/ETH/SOL/DOGE/TON with 24h change, picked by phrase keywords. + do_fun_ask: joke / fact / quote / compliment via Groq at temp 1.0 with per-kind system prompts, random seed in user message. + do_wiki_lookup: ru.wikipedia opensearch → page summary, optional Groq retell when extract > 350 chars. + do_power: dispatch table for shutdown / restart / sleep / hibernate / logoff / cancel — runs shutdown.exe /s|/r|/h|/l|/a or rundll32 powrprof,SetSuspendState. 30-second grace on shutdown and restart with /c message so "отмени выключение" works. + do_help_commands: sorts VA_CMD_LIST.keys(), speaks first 12 + total, full list to clipboard. commands.yaml +26 entries: news_headlines, currency_rate, crypto_price, joke / fact / quote / compliment (action:fun_ask, kind: ...), wiki_lookup, shutdown_pc / restart_pc / sleep_pc / hibernate_pc / logoff_user / cancel_shutdown (action:power, op: ...), help. Inspired by what mainstream voice assistants (Siri/Alexa/Алиса/ Cortana) ship by default + power-user utilities (shutdown, window/process control).
2026-05-15 12:43:26 +03:00
import urllib.request as _urlreq
import urllib.parse as _urlparse
def _http_get(url: str, timeout: float = 15.0) -> str:
req = _urlreq.Request(url, headers={'User-Agent': 'Jarvis/1.0'})
with _urlreq.urlopen(req, timeout=timeout) as r:
return r.read().decode('utf-8', errors='replace')
def do_news(action, voice: str):
feed = action.get('feed', 'https://lenta.ru/rss/news')
try:
body = _http_get(feed, timeout=15)
except Exception as e:
_speak("Не получил RSS.")
print(f"news fetch failed: {e}")
return
titles = _re_reminders.findall(r'<title>\s*<!\[CDATA\[(.+?)\]\]>\s*</title>', body)
if not titles:
titles = _re_reminders.findall(r'<title>\s*(.+?)\s*</title>', body)
titles = [t.strip() for t in titles if t.strip()]
if len(titles) <= 1:
_speak("Не получилось распарсить новости.")
return
top = titles[1:6]
joined = ' | '.join(top)
_set_clipboard(joined)
say = None
if config.GROQ_TOKEN:
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[
{'role': 'system', 'content': 'Ты диктор. Сжато перескажи 3 главные темы из списка заголовков, 2-3 предложения.'},
{'role': 'user', 'content': joined},
],
max_tokens=220, temperature=0.4,
)
say = resp.choices[0].message.content.strip()
except Exception as e:
print(f"news LLM summary failed: {e}")
if not say:
say = "Главные новости: " + ". ".join(top[:3])
_speak(say)
_CURRENCY_LABELS = {'USD': 'Доллар', 'EUR': 'Евро', 'CNY': 'Юань',
'GBP': 'Фунт', 'JPY': 'Йена', 'KZT': 'Тенге'}
def do_currency_rate(action, voice: str):
lower = voice.lower()
code = 'USD'
if 'евро' in lower or 'eur' in lower: code = 'EUR'
elif 'юан' in lower or 'cny' in lower: code = 'CNY'
elif 'фунт' in lower: code = 'GBP'
elif 'йен' in lower: code = 'JPY'
elif 'тенге' in lower: code = 'KZT'
try:
data = json.loads(_http_get('https://www.cbr-xml-daily.ru/daily_json.js', timeout=10))
except Exception as e:
_speak("Не получил данные ЦБ.")
print(f"cbr fetch failed: {e}")
return
v = data.get('Valute', {}).get(code)
if not v:
_speak(f"Нет данных по {code}.")
return
val = v['Value'] / max(1, v.get('Nominal', 1))
prev = v.get('Previous', v['Value']) / max(1, v.get('Nominal', 1))
diff = val - prev
label = _CURRENCY_LABELS.get(code, code)
if diff > 0.01:
_speak(f"{label} стоит {val:.2f} рубля, поднялся на {diff:.2f}.")
elif diff < -0.01:
_speak(f"{label} стоит {val:.2f} рубля, опустился на {abs(diff):.2f}.")
else:
_speak(f"{label} стоит {val:.2f} рубля, без изменений.")
def do_crypto_price(action, voice: str):
lower = voice.lower()
if 'эфир' in lower or 'eth' in lower or 'ethereum' in lower:
coin, label = 'ethereum', 'Эфир'
elif 'солан' in lower: coin, label = 'solana', 'Солана'
elif 'doge' in lower or 'дог' in lower: coin, label = 'dogecoin', 'Догикоин'
elif 'ton' in lower or 'тон' in lower: coin, label = 'the-open-network', 'TON'
else: coin, label = 'bitcoin', 'Биткоин'
try:
url = (f"https://api.coingecko.com/api/v3/simple/price?ids={coin}"
"&vs_currencies=usd,rub&include_24hr_change=true")
data = json.loads(_http_get(url, timeout=10)).get(coin, {})
except Exception as e:
_speak("CoinGecko не ответил.")
print(f"coingecko fetch failed: {e}")
return
if not data:
_speak(f"Нет данных по {label}.")
return
usd = data.get('usd', 0)
ch = data.get('usd_24h_change', 0)
if ch > 1:
_speak(f"{label} стоит {int(usd)} долларов, за сутки вырос на {ch:.1f} процентов.")
elif ch < -1:
_speak(f"{label} стоит {int(usd)} долларов, за сутки упал на {abs(ch):.1f} процентов.")
else:
_speak(f"{label} стоит {int(usd)} долларов, без существенных изменений.")
_FUN_PROMPTS = {
'joke': "Расскажи одну короткую шутку на русском, 1-3 предложения. Без вступления.",
'fact': "Расскажи один интересный неочевидный факт. 1-3 предложения. Без 'вот факт'.",
'quote': "Дай вдохновляющую цитату известного автора. Формат: <цитата> — <автор>.",
'compliment': "Сделай искренний короткий комплимент пользователю, как Джарвис-дворецкий. 1-2 предложения, обращение «сэр».",
}
def do_fun_ask(action, voice: str):
kind = action.get('kind', 'joke')
sys_prompt = _FUN_PROMPTS.get(kind, _FUN_PROMPTS['joke'])
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[
{'role': 'system', 'content': sys_prompt},
{'role': 'user', 'content': f'seed {random.randint(1, 9999)}'},
],
max_tokens=220, temperature=1.0,
)
text = resp.choices[0].message.content.strip()
except Exception as e:
print(f"fun api failed: {e}")
_speak("Ошибка.")
return
_speak(text)
_WIKI_TRIGGERS = ('найди в википедии про', 'википедия про', 'расскажи про',
'кто такая', 'кто такой', 'что такое',
'tell me about', 'wikipedia about', 'who is', 'what is',
'розкажи про', 'хто такий', 'що таке')
def do_wiki_lookup(action, voice: str):
query = _strip_trigger(voice, _WIKI_TRIGGERS).strip()
if not query:
_speak("О чём рассказать?")
return
host = 'ru.wikipedia.org'
try:
search_url = (f"https://{host}/w/api.php?action=opensearch"
f"&search={_urlparse.quote(query)}&limit=1&format=json")
search_data = json.loads(_http_get(search_url, timeout=10))
if len(search_data) < 2 or not search_data[1]:
_speak(f"Не нашёл {query}.")
return
title = search_data[1][0]
summary_url = f"https://{host}/api/rest_v1/page/summary/{_urlparse.quote(title)}"
summary = json.loads(_http_get(summary_url, timeout=10))
except Exception as e:
_speak("Википедия не ответила.")
print(f"wiki fetch failed: {e}")
return
extract = summary.get('extract', '')
if not extract:
_speak("Не получил конспект.")
return
say = extract[:400]
if config.GROQ_TOKEN and len(extract) > 350:
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[
{'role': 'system', 'content': "Перескажи текст из Википедии простым языком в 2-3 предложения. Без 'википедия гласит'."},
{'role': 'user', 'content': extract[:2500]},
],
max_tokens=220, temperature=0.3,
)
say = resp.choices[0].message.content.strip()
except Exception as e:
print(f"wiki LLM failed: {e}")
_set_clipboard(f"{summary.get('title', title)}\n\n{extract}")
_speak(say)
_POWER_ACTIONS = {
'shutdown': (['shutdown.exe', '/s', '/t', '30', '/c', 'J.A.R.V.I.S.: shutdown in 30s'], "Выключение через 30 секунд."),
'restart': (['shutdown.exe', '/r', '/t', '30', '/c', 'J.A.R.V.I.S.: restart in 30s'], "Перезагрузка через 30 секунд."),
'logoff': (['shutdown.exe', '/l'], "Выход из системы."),
'sleep': (['rundll32.exe', 'powrprof.dll,SetSuspendState', '0,1,0'], "Перехожу в сон."),
'hibernate': (['shutdown.exe', '/h'], "Гибернация."),
'cancel': (['shutdown.exe', '/a'], "Выключение отменено."),
}
def do_power(action, voice: str):
op = action.get('op', 'cancel')
cmd, label = _POWER_ACTIONS.get(op, _POWER_ACTIONS['cancel'])
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if res.returncode == 0 or op == 'cancel':
tail = " Скажи отмени если передумал." if op in ('shutdown', 'restart') else ""
_speak(label + tail)
else:
_speak(f"Не получилось: {op}.")
print(f"power {op} failed: {res.stderr[:200]}")
def do_help_commands(action, voice: str):
names = sorted(VA_CMD_LIST.keys())
preview = ', '.join(names[:12])
_set_clipboard('\n'.join(names))
_speak(f"Загружено {len(names)} команд. Например: {preview}. Полный список в буфере.")
_GAME_TRIGGERS = ('запусти игру', 'включи игру', 'хочу поиграть', 'поиграем',
'запусти', 'включи', 'launch game', 'play game', 'launch',
'запусти гру', 'увімкни гру')
def _load_games_config():
user_profile = os.environ.get('USERPROFILE', os.path.expanduser('~'))
cfg_path = os.path.join(user_profile, 'Documents', 'jarvis-games.json')
if not os.path.isfile(cfg_path):
sample = [
{"name": "dota", "aliases": ["доту", "доту 2", "dota 2"], "steam_appid": 570},
{"name": "cs2", "aliases": ["кс", "контру", "контр страйк"], "steam_appid": 730},
{"name": "elden ring", "aliases": ["элден", "элден ринг"], "steam_appid": 1245620},
{"name": "witcher 3", "aliases": ["ведьмак", "ведьмака 3"], "steam_appid": 292030},
{"name": "factorio", "aliases": ["факторио"], "steam_appid": 427520},
]
os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
with open(cfg_path, 'w', encoding='utf-8') as f:
json.dump(sample, f, ensure_ascii=False, indent=2)
return cfg_path, sample, True
try:
with open(cfg_path, 'r', encoding='utf-8') as f:
return cfg_path, json.load(f), False
except (OSError, json.JSONDecodeError) as e:
print(f"games config read failed: {e}")
return cfg_path, [], False
def do_launch_game(action, voice: str):
cfg_path, entries, created = _load_games_config()
if created:
_speak("Создал шаблон, добавь свои игры и повтори.")
subprocess.Popen(['cmd', '/c', 'start', '', cfg_path])
return
query = _strip_trigger(voice, _GAME_TRIGGERS).strip().lower()
if not query:
_speak("Какую игру?")
return
def name_match(e, q):
if e['name'].lower() == q: return True
if q in e['name'].lower(): return True
for a in e.get('aliases', []):
if a.lower() == q or a.lower() in q: return True
return False
match = next((e for e in entries if name_match(e, query)), None)
if not match:
_speak(f"Не нашёл {query}.")
return
appid = match.get('steam_appid')
path = match.get('path')
epic_uri = match.get('epic_uri')
if appid:
webbrowser.open(f"steam://rungameid/{appid}")
elif epic_uri:
webbrowser.open(epic_uri)
elif path and os.path.isfile(path):
subprocess.Popen(['cmd', '/c', 'start', '', path])
else:
_speak(f"Не настроен запуск для {match['name']}.")
return
_speak(f"Запускаю {match['name']}.")
def do_list_games(action, voice: str):
cfg_path, entries, created = _load_games_config()
if created or not entries:
_speak("Список пуст.")
return
names = [e['name'] for e in entries]
_speak(f"В библиотеке {len(names)}: {', '.join(names)}.")
_MOUSE_PS = r"""
Add-Type -Name MouseM -Namespace Win32 -MemberDefinition @'
[DllImport("user32.dll")]
public static extern void mouse_event(uint flags, int dx, int dy, int data, System.UIntPtr extra);
'@
$LD=0x0002; $LU=0x0004; $RD=0x0008; $RU=0x0010; $MD=0x0020; $MU=0x0040; $WHEEL=0x0800
function Click([uint32]$d,[uint32]$u){ [Win32.MouseM]::mouse_event($d,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Win32.MouseM]::mouse_event($u,0,0,0,[UIntPtr]::Zero) }
switch ($args[0]) {
'left' { Click $LD $LU }
'right' { Click $RD $RU }
'middle' { Click $MD $MU }
'double' { Click $LD $LU; Start-Sleep -Milliseconds 50; Click $LD $LU }
'scroll_up' { [Win32.MouseM]::mouse_event($WHEEL,0,0, 360,[UIntPtr]::Zero) }
'scroll_down' { [Win32.MouseM]::mouse_event($WHEEL,0,0,-360,[UIntPtr]::Zero) }
}
""".strip()
def do_mouse(action, voice: str):
op = action.get('op', 'left')
subprocess.run(
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', _MOUSE_PS, op],
capture_output=True, timeout=5,
)
_CHOICE_TRIGGERS = ('выбери из', 'выбери', 'решай за меня', 'выбрать',
'pick from', 'choose from', 'decide between',
'обери з', 'вибери')
def do_random_choice(action, voice: str):
rest = _strip_trigger(voice, _CHOICE_TRIGGERS).strip().lower()
options = None
for sep in (' или ', ' or ', ', ', ' либо ', ' чи '):
if sep in rest:
options = [t.strip() for t in rest.split(sep) if t.strip()]
break
if not options or len(options) < 2:
_speak("Скажи: выбери из X или Y или Z.")
return
_speak(f"Я выбираю {random.choice(options)}.")
_BRIGHTNESS_PS = (
"$ErrorActionPreference='SilentlyContinue'; "
"try { $cur = (Get-WmiObject -Namespace root\\WMI -Class WmiMonitorBrightness -EA Stop).CurrentBrightness } "
"catch { Write-Output 'no_wmi'; exit 0 } "
"$target = switch ('__OP__') { 'up'{[Math]::Min(100,$cur+20)} 'down'{[Math]::Max(0,$cur-20)} 'max'{100} 'min'{10} default{$cur} } "
"(Get-WmiObject -Namespace root\\WMI -Class WmiMonitorBrightnessMethods).WmiSetBrightness(1,$target) | Out-Null; "
"Write-Output $target"
)
def do_brightness(action, voice: str):
op = action.get('op', 'up')
ps = _BRIGHTNESS_PS.replace('__OP__', op)
res = subprocess.run(
['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=10,
)
out = (res.stdout or '').strip()
if out == 'no_wmi' or not out:
_speak("Не поддерживается на этом дисплее.")
return
_speak(f"Яркость {out} процентов.")
_WINDOW_ALIASES = {
'хром': 'chrome', 'хрома': 'chrome', 'едж': 'msedge', 'эдж': 'msedge',
'файрфокс': 'firefox', 'фаерфокс': 'firefox',
'вскод': 'code', 'vs code': 'code',
'дискорд': 'discord', 'телега': 'telegram', 'телеграм': 'telegram',
'стим': 'steam', 'обс': 'obs64',
'проводник': 'explorer', 'блокнот': 'notepad',
'терминал': 'windowsterminal', 'спотифай': 'spotify',
'яндекс': 'browser', 'браузер': 'browser',
}
_WINDOW_TRIGGERS = ('переключись на', 'переключи на', 'активируй окно',
'покажи окно', 'перейди в',
'switch to', 'activate window', 'focus window',
'перемкни на')
def do_window_switch(action, voice: str):
query = _strip_trigger(voice, _WINDOW_TRIGGERS).strip().lower()
if not query:
_speak("Какое окно?")
return
target = _WINDOW_ALIASES.get(query, query).replace("'", "")
ps = (
f"$procs = Get-Process | Where-Object {{ $_.MainWindowTitle -and "
f"($_.ProcessName -like '*{target}*' -or $_.MainWindowTitle -like '*{target}*') }} "
f"| Sort-Object -Property StartTime -Descending; "
f"if ($procs.Count -eq 0) {{ Write-Output 'NOT_FOUND'; exit 0 }}; "
f"$p = $procs[0]; "
f"$wsh = New-Object -ComObject WScript.Shell; "
f"$null = $wsh.AppActivate($p.Id); "
f"Write-Output $p.MainWindowTitle"
)
res = subprocess.run(['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=10)
out = (res.stdout or '').strip()
if out == 'NOT_FOUND' or not out:
_speak(f"Не нашёл окно {query}.")
return
_speak(f"Переключаюсь на {out[:60]}.")
_SPELLING_TRIGGERS = ('продиктуй по буквам', 'произнеси по буквам',
'произнеси буквы', 'по буквам',
'spell out', 'spell it', 'вимов по буквах')
def do_spell_out(action, voice: str):
word = _strip_trigger(voice, _SPELLING_TRIGGERS).strip()
if not word:
_speak("Что произнести?")
return
letters = [ch for ch in word if not ch.isspace()]
if not letters:
return
_speak('. '.join(letters) + '.')
_NETWORK_URIS = {
'wifi': 'ms-settings:network-wifi',
'bluetooth': 'ms-settings:bluetooth',
'network': 'ms-settings:network',
}
def do_network_settings(action, voice: str):
section = action.get('section', 'network')
uri = _NETWORK_URIS.get(section, _NETWORK_URIS['network'])
subprocess.Popen(['cmd', '/c', 'start', '', uri], shell=False)
label = {'wifi': 'Wi-Fi', 'bluetooth': 'Bluetooth', 'network': 'сети'}.get(section, 'сети')
_speak(f"Открываю настройки {label}.")
def do_my_ip(action, voice: str):
ps = (
"$lan = (Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | "
"Where-Object { $_.PrefixOrigin -in 'Dhcp','Manual' } | "
"Where-Object { $_.IPAddress -notmatch '^127\\.' -and $_.IPAddress -notmatch '^169\\.254\\.' } | "
"Select-Object -First 1).IPAddress; "
"if (-not $lan) { $lan = '(none)' }; "
"$wan = try { (Invoke-RestMethod -Uri 'https://api.ipify.org?format=json' -TimeoutSec 5).ip } catch { 'unavailable' }; "
"Write-Output ('LAN ' + $lan + ' WAN ' + $wan)"
)
res = subprocess.run(['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=15)
out = (res.stdout or '').strip()
if not out:
_speak("Не получил IP.")
return
say = out.replace('LAN', 'Локальный').replace('WAN', '. Внешний')
_speak(say)
_SUMMARIZE_TRIGGERS = ('суммируй', 'перескажи выделенное', 'кратко перескажи',
'о чём это', 'что выделил', 'перескажи это',
'summarise this', 'summarize selection', 'tldr',
'перекажи це', 'стисло перекажи')
def do_summarize_selection(action, voice: str):
if not config.GROQ_TOKEN:
_speak("Groq токен не задан.")
return
try:
import pyautogui
pyautogui.hotkey('ctrl', 'c')
except Exception as e:
print(f"summarize copy failed: {e}")
_speak("Не удалось скопировать выделение.")
return
time.sleep(0.25)
res = subprocess.run(['powershell', '-NoProfile', '-Command', 'Get-Clipboard'],
capture_output=True, text=True, encoding='utf-8', timeout=10)
text = (res.stdout or '').strip()
if len(text) < 20:
_speak("Сначала выдели текст и повтори.")
return
if len(text) > 8000:
text = text[:8000] + " ..."
sys_prompt = ("Перескажи следующий текст одним абзацем (3-5 предложений). "
"Сохрани ключевые факты и имена, без 'вот суммарно'.")
try:
resp = client.chat.completions.create(
model=config.GROQ_MODEL,
messages=[{'role': 'system', 'content': sys_prompt},
{'role': 'user', 'content': text}],
max_tokens=400, temperature=0.3,
)
summary = resp.choices[0].message.content.strip()
except Exception as e:
print(f"summarize LLM failed: {e}")
_speak("LLM не ответила.")
return
_set_clipboard(summary)
_speak(summary)
def do_stopwatch(action, voice: str):
op = action.get('op', 'check')
state = _stopwatch_state_path()
def fmt(sec):
sec = int(sec)
if sec < 60: return f"{sec} секунд"
if sec < 3600: return f"{sec // 60} мин {sec % 60} сек"
return f"{sec // 3600} ч {(sec % 3600) // 60} мин {sec % 60} сек"
if op == 'start':
with open(state, 'w', encoding='utf-8') as f:
f.write(str(time.time()))
_speak("Секундомер запущен.")
return
if not os.path.isfile(state):
_speak("Секундомер не запущен.")
return
try:
started = float(open(state, 'r', encoding='utf-8').read().strip())
except (OSError, ValueError):
_speak("Секундомер не запущен.")
return
elapsed = time.time() - started
if op == 'stop':
try: os.unlink(state)
except OSError: pass
_speak(f"Прошло {fmt(elapsed)}.")
else:
_speak(f"Прошло {fmt(elapsed)}.")
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
def do_sysinfo(action, voice: str):
topic = action.get('topic', 'all')
if topic in ('time', 'all'):
now = datetime.datetime.now()
weekday = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'][now.weekday()]
_speak(f"Сейчас {now.strftime('%H:%M')}, {weekday}.")
if topic == 'time':
return
ps_scripts = {
'battery': '$b=Get-CimInstance Win32_Battery -EA SilentlyContinue; if($b){"$([int]$b.EstimatedChargeRemaining)%"}else{"батареи нет"}',
'cpu': '$c=Get-CimInstance Win32_Processor -EA SilentlyContinue|Select-Object -First 1; $load=(Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -Filter "Name=\'_Total\'" -EA SilentlyContinue).PercentProcessorTime; if($null -eq $load){$load=$c.LoadPercentage}; "$load%"',
'ram': '$o=Get-CimInstance Win32_OperatingSystem; $tg=[math]::Round($o.TotalVisibleMemorySize/1MB,1); $ug=[math]::Round(($o.TotalVisibleMemorySize-$o.FreePhysicalMemory)/1MB,1); "$ug из $tg гигабайт"',
'disk': '$d=Get-CimInstance Win32_LogicalDisk -Filter "DeviceID=\'$env:SystemDrive\'"; $fg=[math]::Round($d.FreeSpace/1GB,0); $tg=[math]::Round($d.Size/1GB,0); "свободно $fg из $tg гигабайт"',
}
labels = {'battery': 'Питание', 'cpu': 'Процессор', 'ram': 'Память', 'disk': 'Диск'}
order = ['battery', 'cpu', 'ram', 'disk'] if topic == 'all' else [topic]
for t in order:
ps = ps_scripts.get(t)
if not ps:
continue
result = subprocess.run(
['powershell', '-NoProfile', '-Command', ps],
capture_output=True, text=True, encoding='utf-8', timeout=10,
)
out = result.stdout.strip() if result.returncode == 0 else 'ошибка'
_speak(f"{labels[t]}: {out}.")
def run_action(action):
t = action['type']
if t == 'exe':
path = f"{CDIR}\\custom-commands\\{action['file']}"
if action.get('wait'):
subprocess.check_call([path])
else:
subprocess.Popen([path])
if 'delay_ms' in action:
time.sleep(action['delay_ms'] / 1000)
elif t == 'play_sound':
play(action['name'])
elif t == 'system':
op = action['op']
if op == 'volume_mute':
_set_mute(1)
elif op == 'volume_unmute':
_set_mute(0)
elif op == 'exit':
_shutdown()
elif t == 'sleep':
time.sleep(action['ms'] / 1000)
elif t == 'shell':
if action.get('wait'):
subprocess.check_call(action['cmd'], shell=True)
else:
subprocess.Popen(action['cmd'], shell=True)
elif t == 'url':
browser = action.get('browser')
if browser:
exe = config.BROWSER_PATHS.get(browser)
if exe and os.path.isfile(exe):
subprocess.Popen([exe, action['href']])
return
webbrowser.open(action['href'])
elif t == 'keys':
import pyautogui
if action.get('sequence'):
keys = [k.strip() for k in action['sequence'].split('+') if k.strip()]
pyautogui.hotkey(*keys)
elif action.get('text'):
pyautogui.typewrite(action['text'], interval=0.02)
elif t == 'multi':
for step in action['steps']:
run_action(step)
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
elif t == 'groq_codegen':
do_codegen(action, _current_voice or '')
elif t == 'ocr_screen':
do_ocr(action, _current_voice or '')
elif t == 'sysinfo':
do_sysinfo(action, _current_voice or '')
elif t == 'file_search':
do_file_search(action, _current_voice or '')
elif t == 'audio_devices_panel':
do_audio_devices_panel(action, _current_voice or '')
feat: tier-1 PC tools parity — window mgmt, clipboard, notes, process killer, web search Mirrors the new rust packs from the same iteration. Total commands in commands.yaml is now 49 (was 33). yaml.safe_load + ast.parse both pass. Window management (8 commands) — uses the existing `keys` action with pyautogui hotkey strings: winleft+d (show_desktop), winleft+up/down (maximize/minimize), winleft+left/right (snap left/right), alt+f4 (close_window), winleft+shift+m (restore_all), winleft+tab (task_view). No new action type needed. Clipboard read aloud — new clipboard_read action. Shells to PowerShell Get-Clipboard, caps preview at 400 chars, speaks via the existing tts.va_speak (which already handles recorder.stop/start). Notes — new notes_add + notes_open actions. notes_add strips the trigger phrase, appends "[YYYY-MM-DD HH:MM] body\n" to %USERPROFILE%\Documents\jarvis-notes.txt, creates the dir if needed. notes_open shells "cmd /c start" so .txt opens in the user-default editor. Process killer — new process_kill action. Strips the trigger ("убей процесс"/"закрой программу"/etc.), looks up the remainder in the same alias map as the rust version (хром→chrome, телега→telegram, спотифай →spotify, едж/эдж/edge→msedge, дискорд→discord, стим→steam, обс→obs64, проводник/блокнот/калькулятор/диспетчер задач, vs code→code, etc.), appends .exe if needed, runs taskkill /F /IM, speaks success or "не нашёл процесс N". Web search — new websearch action with a `service` param. Bases: google → https://www.google.com/search?q= youtube → https://www.youtube.com/results?search_query= wiki → https://ru.wikipedia.org/wiki/Special:Search?search= yandex → https://yandex.ru/search/?text= Strips trigger, urllib.parse.quote-s the rest, opens via webbrowser.open (respects the user'\''s default browser; config.BROWSER_PATHS only kicks in for the older url-action commands). Portability: USERPROFILE used for notes path, no other hardcoded paths.
2026-05-15 11:27:19 +03:00
elif t == 'clipboard_read':
do_clipboard_read(action, _current_voice or '')
elif t == 'notes_add':
do_notes_add(action, _current_voice or '')
elif t == 'notes_open':
do_notes_open(action, _current_voice or '')
elif t == 'process_kill':
do_process_kill(action, _current_voice or '')
elif t == 'websearch':
do_websearch(action, _current_voice or '')
feat: tier-1 parity — translate, math, theme toggle, projects, sound panel (56 commands total) Mirrors the rust packs from the same iteration. yaml.safe_load + ast.parse both pass. main.py — six new action handlers + a _speak_in_lang helper: + do_translate: picks target language from "переведи на X" / "translate to X" triggers, defaults to English, sends to Groq with strict "translation only" prompt, drops to clipboard, speaks via SAPI in matching ISO voice + do_math_eval: strips trigger, Groq at temp=0.0 max_tokens=64, short-circuits on "нет" sentinel for non-math + do_theme_set: writes AppsUseLightTheme + SystemUsesLightTheme via Set-ItemProperty under HKCU\...\Themes\Personalize, action param mode: dark|light + do_open_project / do_list_projects: reads/creates %USERPROFILE%\Documents\jarvis-projects.json (a flat list of {name, path}), exact-match → substring-match, opens in VS Code if code.cmd on PATH else Explorer. First call writes a sample config pointing under USERPROFILE so it works on any host. + do_open_sound_panel: rundll32 mmsys.cpl,,1 — Recording tab commands.yaml — 8 new entries: + translate, math_eval + theme_dark, theme_light (action params mode:dark/light) + open_project, list_projects + open_sound_panel SAPI voice selection iterates GetInstalledVoices() and picks first matching TwoLetterISOLanguageName — Russian phrases sound Russian, English/German/etc. sound right too, falls back to default if no matching voice is installed. Total commands: 56 (was 49).
2026-05-15 12:06:44 +03:00
elif t == 'translate':
do_translate(action, _current_voice or '')
elif t == 'math_eval':
do_math_eval(action, _current_voice or '')
elif t == 'theme_set':
do_theme_set(action, _current_voice or '')
elif t == 'open_project':
do_open_project(action, _current_voice or '')
elif t == 'list_projects':
do_list_projects(action, _current_voice or '')
elif t == 'open_sound_panel':
do_open_sound_panel(action, _current_voice or '')
elif t == 'set_reminder':
do_set_reminder(action, _current_voice or '')
elif t == 'date_query':
do_date_query(action, _current_voice or '')
elif t == 'type_text':
do_type_text(action, _current_voice or '')
elif t == 'dice':
do_dice(action, _current_voice or '')
elif t == 'stopwatch':
do_stopwatch(action, _current_voice or '')
feat: parity — news, currency, crypto, fun, wiki, power, help (82 commands) Mirrors the rust analog-inspired tier-2 packs. yaml + ast smoke pass. 56 → 82 commands (+26 yaml entries; 14 of them are power/fun/wiki/ news/etc. permutations). main.py — 7 new action handlers, all built on stdlib urllib.request (no new dep). Helper _http_get() with User-Agent header so RSS feeds that 403 default UAs go through. + do_news: pulls RSS (default Lenta.ru), extracts <title> via regex, skips channel title (idx 0), takes 5, sends to Groq for a 2-3 sentence summary at temp 0.4. Falls back to reading first 3 headlines verbatim if no token. + do_currency_rate: cbr-xml-daily.ru daily_json.js. Recognises USD/EUR/CNY/GBP/JPY/KZT in the phrase, computes value/nominal, diffs against Previous, speaks Russian with direction. + do_crypto_price: CoinGecko simple/price for BTC/ETH/SOL/DOGE/TON with 24h change, picked by phrase keywords. + do_fun_ask: joke / fact / quote / compliment via Groq at temp 1.0 with per-kind system prompts, random seed in user message. + do_wiki_lookup: ru.wikipedia opensearch → page summary, optional Groq retell when extract > 350 chars. + do_power: dispatch table for shutdown / restart / sleep / hibernate / logoff / cancel — runs shutdown.exe /s|/r|/h|/l|/a or rundll32 powrprof,SetSuspendState. 30-second grace on shutdown and restart with /c message so "отмени выключение" works. + do_help_commands: sorts VA_CMD_LIST.keys(), speaks first 12 + total, full list to clipboard. commands.yaml +26 entries: news_headlines, currency_rate, crypto_price, joke / fact / quote / compliment (action:fun_ask, kind: ...), wiki_lookup, shutdown_pc / restart_pc / sleep_pc / hibernate_pc / logoff_user / cancel_shutdown (action:power, op: ...), help. Inspired by what mainstream voice assistants (Siri/Alexa/Алиса/ Cortana) ship by default + power-user utilities (shutdown, window/process control).
2026-05-15 12:43:26 +03:00
elif t == 'news':
do_news(action, _current_voice or '')
elif t == 'currency_rate':
do_currency_rate(action, _current_voice or '')
elif t == 'crypto_price':
do_crypto_price(action, _current_voice or '')
elif t == 'fun_ask':
do_fun_ask(action, _current_voice or '')
elif t == 'wiki_lookup':
do_wiki_lookup(action, _current_voice or '')
elif t == 'power':
do_power(action, _current_voice or '')
elif t == 'help_commands':
do_help_commands(action, _current_voice or '')
elif t == 'launch_game':
do_launch_game(action, _current_voice or '')
elif t == 'list_games':
do_list_games(action, _current_voice or '')
elif t == 'mouse':
do_mouse(action, _current_voice or '')
elif t == 'random_choice':
do_random_choice(action, _current_voice or '')
elif t == 'brightness':
do_brightness(action, _current_voice or '')
elif t == 'window_switch':
do_window_switch(action, _current_voice or '')
elif t == 'spell_out':
do_spell_out(action, _current_voice or '')
elif t == 'network_settings':
do_network_settings(action, _current_voice or '')
elif t == 'my_ip':
do_my_ip(action, _current_voice or '')
elif t == 'summarize_selection':
do_summarize_selection(action, _current_voice or '')
feat: extensions module + 19 new commands (100 → 119) Mirror of new packs from the rust fork. Self-contained `extensions.py` with new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks from main.py. Wiring (main.py) - `import extensions` at top. - `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)` right after _set_clipboard is defined. - Added 15 new action-type dispatches in run_action. extensions.py (new, ~360 lines) Action types: lock_workstation → rundll32 user32.dll,LockWorkStation screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage OR file → ~/Pictures/Screenshots/jarvis-<ts>.png disk_free / disk_list → PowerShell Get-PSDrive coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard, speaks length only — never echoes the password) ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry wifi_ssid → netsh wlan show interfaces parser (ru + en field names) public_ip → urllib request to api.ipify.org unit_convert {mode: length|weight|temperature|speed} Russian-grammar pluralisation (фут/фута/футов). date_math {mode: days_until|day_of_week} Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>". echo_repeat / self_ping — debug commands interesting_fact → Groq LLM (requires GROQ_TOKEN) commands.yaml — 19 new entries: lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list, coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip, convert_length, convert_weight, convert_temperature, convert_speed, days_until, day_of_week, echo_repeat, self_ping, interesting_fact. Tests: python syntax check + yaml.safe_load both pass. Total commands: 119. NOT YET mirrored from rust (would require deeper rework): memory_pack / profile_switch / scheduler / macros / vision / codebase_qa / github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro / habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer / wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin, password ARE included — others can follow). The reason these weren't mirrored: they need new state files (memory.json, profiles.json, schedule.json, macros.json) and background threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
# ── Extension handlers (ported from rust fork) ──
elif t == 'lock_workstation':
extensions.do_lock_workstation(action, _current_voice or '')
elif t == 'screenshot':
extensions.do_screenshot(action, _current_voice or '')
elif t == 'disk_free':
extensions.do_disk_free(action, _current_voice or '')
elif t == 'disk_list':
extensions.do_disk_list(action, _current_voice or '')
elif t == 'coin_flip':
extensions.do_coin(action, _current_voice or '')
elif t == 'password_gen':
extensions.do_password_gen(action, _current_voice or '')
elif t == 'uuid_gen':
extensions.do_uuid(action, _current_voice or '')
elif t == 'ssl_check':
extensions.do_ssl_check(action, _current_voice or '')
elif t == 'wifi_ssid':
extensions.do_wifi_ssid(action, _current_voice or '')
elif t == 'public_ip':
extensions.do_public_ip(action, _current_voice or '')
elif t == 'unit_convert':
extensions.do_unit_convert(action, _current_voice or '')
elif t == 'date_math':
extensions.do_date_math(action, _current_voice or '')
elif t == 'echo_repeat':
extensions.do_echo_repeat(action, _current_voice or '')
elif t == 'self_ping':
extensions.do_self_ping(action, _current_voice or '')
elif t == 'interesting_fact':
extensions.do_interesting_fact(action, _current_voice or '')
feat: memory_store + profiles_store + 10 new yaml entries (119 → 129) Mirrors `long_term_memory` and `profiles` modules from rust fork. memory_store.py (new, ~95 lines) - JSON store at <here>/long_term_memory.json (atomic write). - remember(key, value) / recall(key) / forget(key) / search(query, limit) / all_facts() / build_llm_context(prompt, limit). - Uses the same JSON shape as the rust side, so the file is interoperable if you ever symlink the two installations to the same dir. profiles_store.py (new, ~145 lines) - Profiles at <here>/profiles/<name>.json. active_profile.txt persists. - Seeds 5 defaults on first init: default ★, work 💼, game 🎮, sleep 🌙, driving 🚗 — same as rust fork. - active() / active_name() / set_active(name) / list_names() / allows_command(cmd_id). extensions.py (+6 handlers, +160 lines) - do_memory_remember — derives key (first 6 words or split on '=') - do_memory_recall — substring search or top-3 if no query - do_memory_forget — exact then substring fallback - do_memory_list — count + first 5 keys - do_profile_set — target via action.target or voice ("работа" → work) - do_profile_what — speaks current profile + description main.py - +6 dispatch entries for the new action types. commands.yaml (+10 entries, 119 → 129) - memory_remember, memory_recall, memory_forget, memory_list - profile_work, profile_game, profile_sleep, profile_driving, profile_default - profile_what Tests: ast.parse passes for all .py files. yaml.safe_load gives 129 entries. Python parity is now meaningful: memory + profiles are usable by voice without any rust install. Still missing for full parity: scheduler (needs background thread), macros (needs phrase-capture state), vision (needs Groq vision model call), codebase_qa, github_pr.
2026-05-15 23:54:33 +03:00
elif t == 'memory_remember':
extensions.do_memory_remember(action, _current_voice or '')
elif t == 'memory_recall':
extensions.do_memory_recall(action, _current_voice or '')
elif t == 'memory_forget':
extensions.do_memory_forget(action, _current_voice or '')
elif t == 'memory_list':
extensions.do_memory_list(action, _current_voice or '')
elif t == 'profile_set':
extensions.do_profile_set(action, _current_voice or '')
elif t == 'profile_what':
extensions.do_profile_what(action, _current_voice or '')
feat: codebase_qa + github_pr in Python (142 → 148 commands) Closes the Python parity gap with rust. All major imba features now mirrored EXCEPT live LLM hot-swap (python LLM backend is still env-driven, not voice- switchable like rust). dev_handlers.py (new, ~250 lines) Codebase Q&A: do_codebase_set "укажи проект C:\path" → memory_store.remember("codebase.root", ...) do_codebase_where "какой проект сейчас" do_codebase_ask "что делает функция X" / "найди в коде Y" Walks the root with same caps as rust: depth=3, files=30, file_bytes=4096, total=50000 Filters by extension (rs/py/ts/lua/go/...). Skips .git/target/node_modules/__pycache__/.venv etc. Feeds digest to Groq LLM with "senior reviewer" prompt. GitHub PR review (requires `gh` CLI authenticated): do_github_set_repo "текущий репо owner/repo" → memory_store do_github_list_prs "какие пиары" → `gh pr list --json number,title,author`, speaks count + first 3 titles do_github_summarize_pr "разбери последний пиар" → gh pr view of latest open PR, feeds title+body+diff stat to LLM, speaks 3-5 sentence review. extensions.py — 6 new proxy handlers + import dev_handlers + set_speak wires it. main.py — 6 new action_type dispatches in run_action. commands.yaml (+6 entries, 142 → 148): codebase_set, codebase_where, codebase_ask, github_set_repo, github_list_prs, github_summarize_pr. Tests: ast.parse passes for all .py files. yaml.safe_load = 148 entries. PYTHON PARITY VS RUST IS NOW COMPLETE except: ✗ LLM hot-swap voice command (rust calls llm::swap_to + persists DB; python config.GROQ_TOKEN is read once at startup) ✗ GUI pages (rust has Tauri+Svelte, python is console-only) Everything else has full parity: memory / profiles / vision / macros / scheduler / codebase Q&A / GitHub PR review / 14 utility packs.
2026-05-16 00:40:08 +03:00
elif t == 'vision_describe':
extensions.do_vision_describe(action, _current_voice or '')
elif t == 'vision_read_error':
extensions.do_vision_read_error(action, _current_voice or '')
elif t == 'macros_start':
extensions.do_macros_start(action, _current_voice or '')
elif t == 'macros_save':
extensions.do_macros_save(action, _current_voice or '')
elif t == 'macros_cancel':
extensions.do_macros_cancel(action, _current_voice or '')
elif t == 'macros_replay':
extensions.do_macros_replay(action, _current_voice or '')
elif t == 'macros_list':
extensions.do_macros_list(action, _current_voice or '')
elif t == 'macros_delete':
extensions.do_macros_delete(action, _current_voice or '')
elif t == 'scheduler_add_reminder':
extensions.do_scheduler_add_reminder(action, _current_voice or '')
elif t == 'scheduler_add_recurring':
extensions.do_scheduler_add_recurring(action, _current_voice or '')
elif t == 'scheduler_list':
extensions.do_scheduler_list(action, _current_voice or '')
elif t == 'scheduler_clear':
extensions.do_scheduler_clear(action, _current_voice or '')
elif t == 'scheduler_cancel_by_text':
extensions.do_scheduler_cancel_by_text(action, _current_voice or '')
elif t == 'codebase_set':
extensions.do_codebase_set(action, _current_voice or '')
elif t == 'codebase_where':
extensions.do_codebase_where(action, _current_voice or '')
elif t == 'codebase_ask':
extensions.do_codebase_ask(action, _current_voice or '')
elif t == 'github_set_repo':
extensions.do_github_set_repo(action, _current_voice or '')
elif t == 'github_list_prs':
extensions.do_github_list_prs(action, _current_voice or '')
elif t == 'github_summarize_pr':
extensions.do_github_summarize_pr(action, _current_voice or '')
feat: LLM hot-swap voice command + Ollama backend (148 → 151 commands) Closes the last functional parity gap with rust. Python now has voice-driven Ollama↔Groq switching, persistent across restarts. llm_backend.py (new, ~130 lines) Singleton module: current_client() → active OpenAI client (or None) current_backend() → 'groq' | 'ollama' | 'none' current_model() → active model name swap_to(name) → hot-swap + persist to <here>/llm_backend.txt parse_backend(name) → ru/en alias normaliser ('облако'→groq, 'локальный'→ollama) Backends: - Groq: config.GROQ_TOKEN + GROQ_BASE_URL + GROQ_MODEL (defaults) - Ollama: OLLAMA_BASE_URL (default http://localhost:11434/v1) + OLLAMA_MODEL (default qwen2.5:3b). api_key='ollama' (placeholder, openai lib insists on a non-empty string; Ollama ignores it). Init precedence (idempotent _ensure_init): 1. Persisted choice from llm_backend.txt 2. JARVIS_LLM env var 3. Auto-detect: Groq if token present, else Ollama Voice commands (commands.yaml, +3 entries → 151) llm_switch_local → ollama llm_switch_cloud → groq llm_status → speaks current backend extensions.py + do_llm_switch / do_llm_status handlers + llm_backend import do_interesting_fact migrated off direct OpenAI(...) to use llm_backend.current_* dev_handlers.py + llm_backend import do_codebase_ask + do_github_summarize_pr migrated to llm_backend.current_* vision_handler.py Vision call documented as Groq-specific (Ollama doesn't expose vision via OpenAI-compat in our stack), kept direct config.GROQ_TOKEN reading. Tests: ast.parse passes for all 5 modules. yaml.safe_load = 151 entries. PYTHON PARITY VS RUST IS NOW FUNCTIONALLY COMPLETE. Only remaining rust-only feature: the Tauri GUI (python is console-only).
2026-05-16 00:44:42 +03:00
elif t == 'llm_switch':
extensions.do_llm_switch(action, _current_voice or '')
elif t == 'llm_status':
extensions.do_llm_status(action, _current_voice or '')
feat+test: pytest suite + Now Playing + World Clock + Daily Quote packs (154 commands) Adds first formal test suite for Python fork — 42 tests covering memory_store, profiles_store, scheduler_store, macros_store, llm_backend. All pass. Tests (tests/, ~290 lines) conftest.py — `isolated_state` fixture: rewires _PATH/_PROFILES_DIR etc to a per-test tmp_path so concurrent runs don't touch real data. test_memory_store.py — 9 tests: remember/recall/forget round-trip, search by key/value, limit/empty query, persistence across reload, build_llm_context test_profiles_store.py — 7 tests: seeded defaults, set_active persistence, allows_command logic (default/work/driving) test_scheduler_store.py — 12 tests: parser (daily/at/every/in, ru units, bad input), add/remove/clear/remove_by_text, _next_fire for each schedule kind test_macros_store.py — 8 tests: is_macro_control filter, start/save round-trip, control-phrase filtering, list_names sorted, replay unknown raises test_llm_backend.py — 6 tests: parse_backend aliases (ru + en), persist round-trip, auto-detect logic Run: cd /c/Jarvis/python && python -m pytest tests/ Three new fun packs (commands.yaml: 151 → 154) now_playing — Windows Media Session API via PowerShell "что играет" / "что за песня" / "какой трек" Works with Spotify, YouTube, Foobar, Yandex Music, anything that exposes SMTC (Win10 1803+). world_clock — worldtimeapi.org (free, no key) "сколько времени в Токио" / "время в Лондоне" 21 Russian + world cities pre-mapped to IANA timezones. daily_quote — zenquotes.io (free, no key) + LLM translation "цитата дня" / "вдохнови меня" Fetches English quote, translates to Russian via active LLM (Groq or Ollama), speaks "text — author." Pack count: 151 → 154. Tests: 42 pytest + ast.parse + yaml.safe_load.
2026-05-16 00:56:02 +03:00
elif t == 'now_playing':
extensions.do_now_playing(action, _current_voice or '')
elif t == 'world_clock':
extensions.do_world_clock(action, _current_voice or '')
elif t == 'daily_quote':
extensions.do_daily_quote(action, _current_voice or '')
elif t == 'magic_8ball':
extensions.do_magic_8ball(action, _current_voice or '')
elif t == 'gh_list_issues':
extensions.do_gh_list_issues(action, _current_voice or '')
elif t == 'gh_my_issues':
extensions.do_gh_my_issues(action, _current_voice or '')
elif t == 'weather_tomorrow':
extensions.do_weather_tomorrow(action, _current_voice or '')
elif t == 'weather_week':
extensions.do_weather_week(action, _current_voice or '')
elif t == 'rss_add':
extensions.do_rss_add(action, _current_voice or '')
elif t == 'rss_read':
extensions.do_rss_read(action, _current_voice or '')
elif t == 'rss_list':
extensions.do_rss_list(action, _current_voice or '')
elif t == 'ics_create':
extensions.do_ics_create(action, _current_voice or '')
elif t == 'backup_export':
extensions.do_backup_export(action, _current_voice or '')
elif t == 'clip_save':
extensions.do_clip_save(action, _current_voice or '')
elif t == 'clip_list':
extensions.do_clip_list(action, _current_voice or '')
elif t == 'clip_restore':
extensions.do_clip_restore(action, _current_voice or '')
elif t == 'notif_missed':
extensions.do_notif_missed(action, _current_voice or '')
elif t == 'notif_clear':
extensions.do_notif_clear(action, _current_voice or '')
elif t == 'vault_save':
extensions.do_vault_save(action, _current_voice or '')
elif t == 'vault_get':
extensions.do_vault_get(action, _current_voice or '')
elif t == 'vault_list':
extensions.do_vault_list(action, _current_voice or '')
elif t == 'habit_checkin':
extensions.do_habit_checkin(action, _current_voice or '')
elif t == 'habit_streak':
extensions.do_habit_streak(action, _current_voice or '')
elif t == 'personality_greet':
extensions.do_personality_greet(action, _current_voice or '')
elif t == 'personality_thanks':
extensions.do_personality_thanks(action, _current_voice or '')
elif t == 'personality_compliment':
extensions.do_personality_compliment(action, _current_voice or '')
elif t == 'personality_how_are_you':
extensions.do_personality_how_are_you(action, _current_voice or '')
elif t == 'personality_tony_quote':
extensions.do_personality_tony_quote(action, _current_voice or '')
elif t == 'tracker_start':
extensions.do_tracker_start(action, _current_voice or '')
elif t == 'tracker_stop':
extensions.do_tracker_stop(action, _current_voice or '')
elif t == 'tracker_today':
extensions.do_tracker_today(action, _current_voice or '')
elif t == 'tracker_week':
extensions.do_tracker_week(action, _current_voice or '')
elif t == 'tracker_reset':
extensions.do_tracker_reset(action, _current_voice or '')
elif t == 'outlook_unread_count':
extensions.do_outlook_unread_count(action, _current_voice or '')
elif t == 'outlook_latest':
extensions.do_outlook_latest(action, _current_voice or '')
elif t == 'outlook_send_clipboard':
extensions.do_outlook_send_clipboard(action, _current_voice or '')
elif t == 'outlook_summarize_inbox':
extensions.do_outlook_summarize_inbox(action, _current_voice or '')
elif t == 'memory_count':
extensions.do_memory_count(action, _current_voice or '')
elif t == 'memory_list':
extensions.do_memory_list(action, _current_voice or '')
elif t == 'memory_wipe':
extensions.do_memory_wipe(action, _current_voice or '')
elif t == 'cooking':
extensions.do_cooking(action, _current_voice or '')
elif t == 'leftoff':
extensions.do_leftoff(action, _current_voice or '')
elif t == 'trivia':
extensions.do_trivia(action, _current_voice or '')
elif t == 'good_night':
extensions.do_good_night(action, _current_voice or '')
elif t == 'good_morning':
extensions.do_good_morning(action, _current_voice or '')
elif t == 'coffee_break':
extensions.do_coffee_break(action, _current_voice or '')
elif t == 'expenses_log':
extensions.do_expenses_log(action, _current_voice or '')
elif t == 'expenses_today':
extensions.do_expenses_today(action, _current_voice or '')
elif t == 'expenses_week':
extensions.do_expenses_week(action, _current_voice or '')
elif t == 'expenses_breakdown':
extensions.do_expenses_breakdown(action, _current_voice or '')
feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests New `wave59_handlers.py` (~250 lines) mirroring four Rust-only packs: - wol: pure-Python magic packet via socket + SO_BROADCAST. No PowerShell shellout (unlike the Rust pack). Reads MAC from memory_store under 'wol_<alias>' or JARVIS_WOL_TARGET env. Same MAC format tolerance (colons / dashes / dots / bare) as the Rust normaliser. - banter: pause/resume state + fire-one. Python edition doesn't run a background banter thread (the Rust daemon already does), so this is just the user-facing surface — same voice phrases work. - conversation: do_conversation_summary calls Groq with last 12 turns from main.py's `message_log`. set_history_ref() injects the live list reference at startup so the handler reads up-to-date history without a circular import. Offline-safe: GROQ_TOKEN missing → speak the last user message instead. do_conversation_repeat re-speaks the last assistant turn. - ddg_answer: urllib + DuckDuckGo Instant Answer JSON. Picks AbstractText → Answer → Definition → RelatedTopics[0]. Opens duckduckgo.com search fallback when nothing instant. extensions.py / main.py / commands.yaml: 7 new dispatch entries + proxy fns. commands.yaml now at 207 entries. tests/test_wave59_handlers.py: 11 unit tests for MAC normalisation, magic packet shape, WOL error paths, banter state machine, DDG trigger stripping, conversation history handling. Network calls deliberately NOT exercised here — they belong to integration tests. Tests: 104 → 115 (+11).
2026-05-24 23:12:04 +03:00
elif t == 'wol':
extensions.do_wol(action, _current_voice or '')
elif t == 'banter_fire':
extensions.do_banter_fire(action, _current_voice or '')
elif t == 'banter_pause':
extensions.do_banter_pause(action, _current_voice or '')
elif t == 'banter_resume':
extensions.do_banter_resume(action, _current_voice or '')
elif t == 'conversation_summary':
extensions.do_conversation_summary(action, _current_voice or '')
elif t == 'conversation_repeat':
extensions.do_conversation_repeat(action, _current_voice or '')
elif t == 'ddg_answer':
extensions.do_ddg_answer(action, _current_voice or '')
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
_current_voice: str = ''
def execute_cmd(cmd: str, voice: str):
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
global _current_voice
spec = VA_CMD_LIST.get(cmd)
if not spec:
return
action = spec['action']
feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
_current_voice = voice
try:
run_action(action)
finally:
_current_voice = ''
confirm = spec.get('confirm_sound')
if confirm is None and action['type'] == 'exe':
confirm = 'ok'
if confirm:
play(confirm)
print("=" * 60)
print(f" J.A.R.V.I.S. v{config.VA_VER} [Python edition]")
print(f" Total commands: {len(VA_CMD_LIST)}")
print("=" * 60)
feat: vision + macros + scheduler in Python (129 → 142 commands) Three big rust packs ported. Python parity now covers the FULL imba lineup except codebase_qa and github_pr. vision_handler.py (new, ~125 lines) - Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL). - do_vision_describe "что на экране" / "опиши экран" - do_vision_read_error "прочитай ошибку" - Requires GROQ_TOKEN; speaks "Не удалось" on missing. macros_store.py (new, ~170 lines) - JSON store at <here>/macros.json (atomic write-through). - start(name) / save() / cancel() / replay(name, dispatch_fn) - is_recording / recording_name / list_names / get / delete - record_step(phrase) called by execute_cmd hook (see main.py) - is_macro_control filter blocks "запиши макрос ..." from recording itself (no infinite recursion). - replay spawns daemon thread, fires each step with 800ms delay. scheduler_store.py (new, ~220 lines) - JSON store at <here>/schedule.json. - parse_schedule(spec) handles "daily HH:MM", "at HH:MM", "every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English. - Background daemon thread ticks every 30s, fires Speak actions through the same speak callback used by main.py. - Once-tasks auto-delete after firing; daily/interval cycle forever. - add/remove/clear/remove_by_text/list_all. extensions.py (+13 handlers) do_vision_describe / do_vision_read_error → thin proxies to vision_handler do_macros_start / save / cancel / replay / list / delete do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text main.py - extensions.init_background_services() at startup → scheduler thread boots. - Wraps execute_cmd so every successful dispatch feeds macros_store.record_step. - set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the normal command pipeline (recognize_cmd → execute_cmd). commands.yaml (+13 entries, 129 → 142) vision_describe, vision_read_error, macros_start, macros_save, macros_cancel, macros_replay, macros_list, macros_delete, scheduler_reminder, scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text. Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries. Python parity status vs rust (72 packs): ✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch (via env), llm_context (basic), media_keys (existing), screenshot, net_info, unit_convert, generators, disk, date_math, sleep_timer (via shutdown), interesting_fact, mailto, self_check, ssl_check, intro/echo ✗ codebase_qa, github_pr (require gh CLI + file walk — TODO) ✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics (could be ported but need scheduler integration + LLM glue)
2026-05-16 00:00:22 +03:00
# Hook macros to the dispatch path: registers a callback so macros::replay
# can re-fire commands, and so the recorder can capture successful phrases.
def _execute_phrase_for_macro(phrase: str):
"""Replay step: re-fire a phrase through the normal dispatch path."""
try:
cmd = recognize_cmd(phrase)
except Exception as exc:
print(f"[macros] replay recognize: {exc}")
return
if cmd:
execute_cmd(cmd, phrase)
extensions.set_dispatch_fn(_execute_phrase_for_macro)
# Wrap execute_cmd to also feed the macro recorder on success.
_orig_execute_cmd = execute_cmd
def execute_cmd(cmd: str, voice: str): # noqa: F811
spec = VA_CMD_LIST.get(cmd)
_orig_execute_cmd(cmd, voice)
if spec:
# On every successful dispatch, feed the recorder. Filter inside
# macros_store.record_step blocks macro-control phrases.
try:
import macros_store
macros_store.record_step(voice)
except Exception as exc:
print(f"[macros] record_step: {exc}")
recorder = PvRecorder(device_index=config.MICROPHONE_INDEX, frame_length=512)
recorder.start()
print('Using device: %s' % recorder.selected_device)
print(f"Jarvis (v{config.VA_VER}) начал свою работу ...")
play("run")
time.sleep(0.5)
def heard_wake_word(text: str) -> bool:
if not text:
return False
lowered = text.lower()
return any(w in lowered for w in config.WAKE_WORDS)
while True:
try:
pcm = recorder.read()
sp = struct.pack("h" * len(pcm), *pcm)
if not wake_rec.AcceptWaveform(sp):
continue
result_text = json.loads(wake_rec.Result()).get("text", "")
if not heard_wake_word(result_text):
continue
play("greet", True)
print("Yes, sir.")
kaldi_rec.Reset()
listen_started = time.time()
speech_ms = 0
silence_ms = 0
vad_buf = b""
finalize = False
cmd_buf = b"" if config.DENOISE_ENABLED else None
while True:
elapsed_ms = (time.time() - listen_started) * 1000
if elapsed_ms >= config.COMMAND_MAX_LISTEN_MS:
break
pcm = recorder.read()
sp = struct.pack("h" * len(pcm), *pcm)
if cmd_buf is None:
kaldi_rec.AcceptWaveform(sp)
else:
cmd_buf += sp
vad_buf += sp
while len(vad_buf) >= VAD_FRAME_BYTES:
frame = vad_buf[:VAD_FRAME_BYTES]
vad_buf = vad_buf[VAD_FRAME_BYTES:]
if vad.is_speech(frame, samplerate):
speech_ms += VAD_FRAME_MS
silence_ms = 0
else:
silence_ms += VAD_FRAME_MS
if elapsed_ms < config.COMMAND_MIN_LISTEN_MS:
continue
if speech_ms >= config.COMMAND_MIN_SPEECH_MS and silence_ms >= config.COMMAND_END_SILENCE_MS:
finalize = True
break
if finalize or speech_ms > 0:
if cmd_buf is not None:
kaldi_rec.AcceptWaveform(denoise_pcm(cmd_buf))
text = json.loads(kaldi_rec.FinalResult()).get("text", "")
if text:
va_respond(text)
wake_rec.Reset()
except Exception as err:
print(f"Unexpected {err=}, {type(err)=}")
raise