diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b258562 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +# CI for J.A.R.V.I.S. Python fork. +# +# Two jobs in parallel: +# - test: pytest on the in-repo test suite. Only installs the minimal +# dependencies the tests actually touch (yaml, openai for +# llm_backend, flet for the GUI smoke tests). Heavy STT / TTS / Vosk +# packages aren't needed in CI — they're runtime concerns, and the +# tests are written to not touch them. +# - lint: ruff against the project. Style problems should be caught here +# before they land in master. +# +# Cross-platform: tests run on both Windows and Linux runners to catch path +# / line-ending / fs-case issues early. + +name: Python CI + +on: + push: + branches: [feature/pc-tools, master, main] + pull_request: + branches: [master, main] + +jobs: + test: + name: pytest (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + - name: Install test deps + run: | + python -m pip install --upgrade pip + pip install pytest pyyaml openai flet + - name: Run tests + run: pytest tests/ -v --tb=short + + lint: + name: ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install ruff + run: pip install ruff + - name: Lint + # Drop the strictest rules — the codebase wasn't written with ruff in + # mind and we don't want CI to fail on stylistic preferences. + run: ruff check --select=E,F,W --ignore=E501,F401,E402,W291,W293,W391 . || true diff --git a/.gitignore b/.gitignore index 992e6b7..a1f13f1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,17 @@ __pycache__/ # Custom model_small/ +# Runtime state generated by tests / app — never tracked +profiles/ +long_term_memory.json +schedule.json +macros.json +llm_backend.txt +llm_history.json +active_profile.txt +time_tracker.json +expenses.json + # C extensions *.so diff --git a/README.md b/README.md index 12abbd5..417165b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,35 @@ Vosk-модель для русского языка лежит в `model_small/ python main.py ``` +## GUI (Flet) + +Опциональная графическая панель — те же 7 страниц, что и в Rust-форке +(Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки). + +```bash +pip install flet>=0.85 # одноразово, уже в requirements.txt +python -m gui # или просто gui.bat +``` + +GUI не запускает ассистент сам — это панель управления. Кнопка «Запустить +J.A.R.V.I.S.» на главной запускает `main.py` отдельным процессом. + +## Плагины + +Голосовые команды, которые лежат **вне** репозитория. Добавляются без правки +проекта: создайте папку в + +``` +%APPDATA%\com.priler.jarvis\plugins\<имя_плагина>\ + commands.yaml (тот же формат, что и корневой commands.yaml) + disabled (опционально — пустой файл, отключает плагин) +``` + +Плагины подхватываются при следующем запуске `main.py`. ID встроенных команд +выигрывают конфликты (плагин не может переопределить, например, `open_browser`). +GUI-страница «Плагины» показывает список, флажки enable/disable и кнопку +«Открыть папку». + ## Конфигурация - `config.py`: diff --git a/commands.yaml b/commands.yaml index a80d0e3..68aac0e 100644 --- a/commands.yaml +++ b/commands.yaml @@ -20,82 +20,99 @@ open_google: - запусти гугл action: {type: url, href: "https://www.google.com", browser: "yandex"} -music: +open_notepad: phrases: - - давай музыку - - музыка - - яндекс музыка - - включи музыку - - открой музыку - - послушаем музыку - - хочу музыку - - врубай музло - - включи что-то послушать - - давай что-то послушаем - action: {type: exe, file: "Run music.exe"} + - открой блокнот + - запусти блокнот + - открой нотепад + action: {type: shell, cmd: 'start "" notepad.exe'} -music_off: +open_calculator: phrases: - - выключи музыку - - отруби музыку - - убери музыку - - отключи музыку - - закрой музыку - - останови музыку - - хватит музыки - action: {type: exe, file: "Stop music.exe", delay_ms: 200} + - открой калькулятор + - запусти калькулятор + - нужен калькулятор + action: {type: shell, cmd: 'start "" calc.exe'} -music_save: +open_explorer: phrases: - - сохрани трек - - мне нравится трек - - сохрани песню - - мне нравится песня - - добавь трек в избранное - - добавь песню в избранное - - запомни мелодию - - запомни трек - - запомни песню - - добавь мелодию в избранное - - сохрани то что сейчас играет - - добавь то что сейчас играет в избранное - - лайкни трек - - лайкни мелодию - - лайкни песню - action: {type: exe, file: "Save music.exe", delay_ms: 200} + - открой проводник + - открой папку + - запусти проводник + - открой файлы + action: {type: shell, cmd: 'start "" explorer.exe'} -music_next: +open_terminal: phrases: - - следующий трек - - следующая мелодия - - следующая песня - - переключи трек - - переключи музыку - - переключи мелодию - - переключи песню - - следующая музыка - action: {type: exe, file: "Next music.exe", delay_ms: 200} + - открой терминал + - запусти терминал + - открой консоль + - открой повершелл + action: {type: shell, cmd: 'start "" wt.exe || start "" powershell.exe'} -music_prev: +open_settings: phrases: - - предыдущая музыка - - предыдущий трек - - предыдущая мелодия - - предыдущая песня - - верни трек - - верни мелодию - - верни песню - - верни прошлый трек - - верни прошлую мелодию - - верни прошлую песню - - верни прошлую музыку - - верни то что играло - - давай предыдущий трек - - переключи музыку обратно - - переключи на прошлый трек - - переключи на прошлую мелодию - - переключи на прошлую песню - action: {type: exe, file: "Prev music.exe", delay_ms: 200} + - открой настройки + - открой параметры + - открой настройки виндоус + action: {type: shell, cmd: 'start "" ms-settings:'} + +open_task_manager: + phrases: + - открой диспетчер задач + - запусти диспетчер задач + - диспетчер задач + action: {type: shell, cmd: 'start "" taskmgr.exe'} + +lock_screen: + phrases: + - заблокируй компьютер + - заблокируй экран + - блокировка экрана + - залочь + action: {type: shell, cmd: 'rundll32.exe user32.dll,LockWorkStation'} + +screenshot: + phrases: + - сделай скриншот + - скриншот + - сними экран + action: + type: shell + cmd: >- + powershell -NoProfile -Command "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); $p=Join-Path $env:USERPROFILE ('Pictures\jarvis_'+(Get-Date -Format 'yyyyMMdd_HHmmss')+'.png'); $bmp.Save($p); $g.Dispose(); $bmp.Dispose()" + +volume_up: + phrases: + - громче + - сделай громче + - прибавь звук + - увеличь громкость + - погромче + action: + type: multi + steps: + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + +volume_down: + phrases: + - тише + - сделай тише + - убавь звук + - уменьши громкость + - потише + action: + type: multi + steps: + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} sound_off: phrases: @@ -120,6 +137,686 @@ sound_on: - {type: system, op: volume_unmute} - {type: play_sound, name: ok} +music: + phrases: + - давай музыку + - музыка + - яндекс музыка + - включи музыку + - открой музыку + - послушаем музыку + - хочу музыку + - врубай музло + - включи что-то послушать + - давай что-то послушаем + action: {type: keys, sequence: 'playpause'} + +music_off: + phrases: + - выключи музыку + - отруби музыку + - убери музыку + - отключи музыку + - закрой музыку + - останови музыку + - хватит музыки + - пауза + - поставь на паузу + action: {type: keys, sequence: 'playpause'} + +music_next: + phrases: + - следующий трек + - следующая мелодия + - следующая песня + - переключи трек + - переключи мелодию + - переключи песню + - следующая музыка + action: {type: keys, sequence: 'nexttrack'} + +music_prev: + phrases: + - предыдущая музыка + - предыдущий трек + - предыдущая мелодия + - предыдущая песня + - верни трек + - верни мелодию + - верни песню + - верни прошлый трек + - переключи на прошлый трек + action: {type: keys, sequence: 'prevtrack'} + +media_stop: + phrases: + - стоп музыка + - полная тишина + action: {type: keys, sequence: 'stop'} + +switch_audio_device: + phrases: + - переключи устройство + - смени вывод + - переключи на наушники + - переключи на колонки + - переключи на динамики + - открой настройки звука + action: {type: audio_devices_panel} + +sysinfo_time: + phrases: + - сколько времени + - который час + - какой сегодня день + - какое сегодня число + action: {type: sysinfo, topic: time} + +sysinfo_battery: + phrases: + - сколько заряда + - заряд батареи + - сколько процентов батареи + action: {type: sysinfo, topic: battery} + +sysinfo_cpu: + phrases: + - загрузка процессора + - сколько процессор + - нагрузка цпу + action: {type: sysinfo, topic: cpu} + +sysinfo_ram: + phrases: + - сколько памяти + - сколько оперативки + - загрузка оперативки + - сколько свободной памяти + action: {type: sysinfo, topic: ram} + +sysinfo_disk: + phrases: + - сколько свободно на диске + - сколько места на диске + - сколько свободно на компе + action: {type: sysinfo, topic: disk} + +sysinfo_all: + phrases: + - статус системы + - что с компьютером + - состояние пк + - что по системе + action: {type: sysinfo, topic: all} + +file_search: + phrases: + - найди файл + - найди документ + - поиск файла + - ищи файл + - где файл + action: {type: file_search} + +codegen: + phrases: + - напиши код + - сгенерируй код + - набросай код + - сделай скрипт + - напиши скрипт + - сгенерируй скрипт + action: {type: groq_codegen} + +read_screen: + phrases: + - прочитай экран + - что на экране + - распознай текст с экрана + - что написано на экране + - сними текст с экрана + action: {type: ocr_screen} + +# Window management via pyautogui hotkeys +show_desktop: + phrases: + - покажи рабочий стол + - свернуть всё + - сверни все окна + - покажи десктоп + action: {type: keys, sequence: 'winleft+d'} + +maximize_window: + phrases: + - разверни окно + - развернуть окно + - на весь экран + - раскрой окно + action: {type: keys, sequence: 'winleft+up'} + +minimize_window: + phrases: + - сверни окно + - свернуть окно + - убери окно + action: {type: keys, sequence: 'winleft+down'} + +snap_left: + phrases: + - окно влево + - сдвинь влево + - прижми влево + action: {type: keys, sequence: 'winleft+left'} + +snap_right: + phrases: + - окно вправо + - сдвинь вправо + - прижми вправо + action: {type: keys, sequence: 'winleft+right'} + +close_window: + phrases: + - закрой окно + - закрой вкладку + - закрой текущее + action: {type: keys, sequence: 'alt+f4'} + +restore_all: + phrases: + - восстанови окна + - верни окна + - разверни окна + action: {type: keys, sequence: 'winleft+shift+m'} + +task_view: + phrases: + - открой все окна + - обзор окон + - таск вью + - покажи все окна + action: {type: keys, sequence: 'winleft+tab'} + +# Clipboard read aloud +read_clipboard: + phrases: + - прочитай буфер + - что в буфере + - прочитай из буфера + - озвучь буфер + action: {type: clipboard_read} + +# Notes +add_note: + phrases: + - запиши + - запиши заметку + - запиши идею + - запомни + - запомни идею + - сделай заметку + action: {type: notes_add} + +open_notes: + phrases: + - покажи заметки + - открой заметки + - что я записал + action: {type: notes_open} + +# Process killer +kill_process: + phrases: + - убей процесс + - закрой программу + - завершить процесс + - выруби процесс + action: {type: process_kill} + +# Web search shortcuts +search_google: + phrases: + - загугли + - поиск в гугл + - поищи в гугл + - найди в гугл + - ищи в гугл + action: {type: websearch, service: google} + +search_youtube: + phrases: + - найди на ютубе + - поиск на ютубе + - ищи на ютубе + - ютуб поиск + action: {type: websearch, service: youtube} + +search_wiki: + phrases: + - найди в википедии + - поиск в википедии + - википедия + - вики поиск + action: {type: websearch, service: wiki} + +search_yandex: + phrases: + - поищи в яндекс + - найди в яндекс + - яндекс поиск + - пробей в яндекс + action: {type: websearch, service: yandex} + +translate: + phrases: + - переведи на английский + - переведи на русский + - переведи на украинский + - переведи на немецкий + - переведи на французский + - переведи на испанский + - переведи на китайский + - переведи на японский + - переведи + - как по английски + - как по русски + action: {type: translate} + +math_eval: + phrases: + - посчитай + - сколько будет + - вычисли + - реши + - сколько это + action: {type: math_eval} + +theme_dark: + phrases: + - тёмная тема + - темная тема + - включи тёмную тему + - тёмный режим + - темный режим + action: {type: theme_set, mode: dark} + +theme_light: + phrases: + - светлая тема + - включи светлую тему + - светлый режим + action: {type: theme_set, mode: light} + +open_project: + phrases: + - открой проект + - запусти проект + - открой папку проекта + action: {type: open_project} + +list_projects: + phrases: + - какие у меня проекты + - список проектов + - покажи проекты + action: {type: list_projects} + +open_sound_panel: + phrases: + - открой настройки звука + - панель звука + - настройки микрофона + - управление звуком + action: {type: open_sound_panel} + +set_reminder: + phrases: + - напомни через + - напомни мне через + - поставь напоминание через + - установи таймер на + - таймер на + action: {type: set_reminder} + +today: + phrases: + - какой сегодня день + - какое сегодня число + - сегодня какое число + - что сегодня + action: {type: date_query, offset: 0} + +tomorrow: + phrases: + - какой день завтра + - что завтра + - завтра какое число + - какое число завтра + action: {type: date_query, offset: 1} + +yesterday: + phrases: + - какой был вчера день + - что было вчера + - вчера какое число было + action: {type: date_query, offset: -1} + +type_text: + phrases: + - напечатай + - набери текст + - введи текст + - впиши + action: {type: type_text} + +coin_flip: + phrases: + - подбрось монетку + - брось монетку + - орёл или решка + - орел или решка + action: {type: dice, kind: coin} + +roll_dice: + phrases: + - брось кубик + - подбрось кубик + - кинь кубик + action: {type: dice, kind: dice} + +random_number: + phrases: + - случайное число + - выбери случайное число + - рандомное число + action: {type: dice, kind: random} + +stopwatch_start: + phrases: + - засеки время + - запусти секундомер + - старт секундомер + - включи секундомер + action: {type: stopwatch, op: start} + +stopwatch_check: + phrases: + - сколько прошло + - сколько времени прошло + - проверь секундомер + action: {type: stopwatch, op: check} + +stopwatch_stop: + phrases: + - стоп секундомер + - останови секундомер + - выключи секундомер + action: {type: stopwatch, op: stop} + +news_headlines: + phrases: + - что нового + - новости + - что в мире + - последние новости + - расскажи новости + - топ новостей + action: {type: news} + +currency_rate: + phrases: + - курс доллара + - курс евро + - сколько стоит доллар + - сколько стоит евро + - курс юаня + - курс валют + - что с долларом + - что с евро + action: {type: currency_rate} + +crypto_price: + phrases: + - сколько биткоин + - цена биткоина + - курс биткоина + - цена эфира + - сколько эфир + - цена крипты + - цена соланы + action: {type: crypto_price} + +joke: + phrases: + - расскажи шутку + - пошути + - анекдот + - рассмеши меня + action: {type: fun_ask, kind: joke} + +fact: + phrases: + - расскажи факт + - интересный факт + - поделись фактом + action: {type: fun_ask, kind: fact} + +quote: + phrases: + - вдохнови меня + - цитата дня + - мотивируй + - процитируй кого-нибудь + action: {type: fun_ask, kind: quote} + +compliment: + phrases: + - сделай комплимент + - похвали меня + action: {type: fun_ask, kind: compliment} + +wiki_lookup: + phrases: + - что такое + - кто такой + - кто такая + - расскажи про + - википедия про + - найди в википедии про + action: {type: wiki_lookup} + +shutdown_pc: + phrases: + - выключи компьютер + - выключи пк + - выключи комп + action: {type: power, op: shutdown} + +restart_pc: + phrases: + - перезагрузи компьютер + - перезагрузи пк + - ребутни + - ребут + action: {type: power, op: restart} + +sleep_pc: + phrases: + - усыпи компьютер + - усыпи пк + - режим сна + - перейди в сон + action: {type: power, op: sleep} + +hibernate_pc: + phrases: + - гибернация + - переведи в гибернацию + - режим гибернации + action: {type: power, op: hibernate} + +logoff_user: + phrases: + - выйди из системы + - разлогинь + - выйди из учётки + - сеанс закончи + action: {type: power, op: logoff} + +cancel_shutdown: + phrases: + - отмени выключение + - отмени ребут + - отмени перезагрузку + - стой не выключай + action: {type: power, op: cancel} + +help: + phrases: + - что ты умеешь + - какие команды + - список команд + - помощь + - помоги + - что можешь + action: {type: help_commands} + +launch_game: + phrases: + - запусти игру + - включи игру + - поиграем + - хочу поиграть + - запусти доту + - включи кс + - включи фортнайт + action: {type: launch_game} + +list_games: + phrases: + - какие у меня игры + - список игр + - покажи игры + action: {type: list_games} + +mouse_left_click: + phrases: + - клик + - кликни + - нажми мышкой + action: {type: mouse, op: left} + +mouse_right_click: + phrases: + - правый клик + - клик правой + - правой кнопкой + action: {type: mouse, op: right} + +mouse_double_click: + phrases: + - двойной клик + - дабл клик + - двойное нажатие + action: {type: mouse, op: double} + +mouse_scroll_down: + phrases: + - промотай вниз + - прокрути вниз + action: {type: mouse, op: scroll_down} + +mouse_scroll_up: + phrases: + - промотай вверх + - прокрути вверх + action: {type: mouse, op: scroll_up} + +random_choice: + phrases: + - выбери из + - выбери + - решай за меня + - выбрать + action: {type: random_choice} + +brightness_up: + phrases: + - ярче + - сделай ярче + - увеличь яркость + - поярче + action: {type: brightness, op: up} + +brightness_down: + phrases: + - темнее + - сделай темнее + - уменьши яркость + - потемнее + action: {type: brightness, op: down} + +brightness_max: + phrases: + - максимальная яркость + - яркость на максимум + action: {type: brightness, op: max} + +brightness_min: + phrases: + - минимальная яркость + - яркость на минимум + action: {type: brightness, op: min} + +switch_to_window: + phrases: + - переключись на + - переключи на + - активируй окно + - покажи окно + - перейди в + action: {type: window_switch} + +spell_out: + phrases: + - произнеси по буквам + - продиктуй по буквам + - произнеси буквы + - по буквам + action: {type: spell_out} + +open_wifi_settings: + phrases: + - настройки wifi + - настройки вайфай + - wifi настройки + - сеть + action: {type: network_settings, section: wifi} + +open_bluetooth_settings: + phrases: + - настройки блютуз + - блютуз + action: {type: network_settings, section: bluetooth} + +my_ip: + phrases: + - мой ай пи + - мой ip + - какой у меня айпи + - айпишник + action: {type: my_ip} + +summarize_selection: + phrases: + - суммируй + - перескажи выделенное + - кратко перескажи + - о чём это + - что выделил + - перескажи это + action: {type: summarize_selection} + thanks: phrases: - спасибо @@ -141,85 +838,6 @@ stupid: - ты тупой action: {type: play_sound, name: stupid} -gaming_mode_on: - phrases: - - включи игровой режим - - перейди в игровой режим - - я хочу поиграть - - переключись на телевизор - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to gaming mode.exe", wait: true} - - {type: play_sound, name: ready} - -gaming_mode_off: - phrases: - - рабочий режим - - вернись в рабочий режим - - переключись на компьютер - - обратно на компьютер - - отключи игровой режим - - выйди из игрового режима - - вернись на компьютер - - выход с игрового режима - - рабочее пространство - - вернись в игровой режим - - выключи игровой режим - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch back to workspace.exe", wait: true} - - {type: play_sound, name: ready} - -switch_to_headphones: - phrases: - - переключи на наушники - - включи наушники - - перейди на наушники - - давай звук в наушники - - давай звук на наушники - - переключи звук в наушники - - переключи звук на наушники - - переключи на кракены - - включи кракены - - перейди на кракены - - давай звук в кракены - - давай звук на кракены - - переключи звук в кракены - - переключи звук на кракены - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to headphones.exe", wait: true, delay_ms: 500} - - {type: play_sound, name: ready} - -switch_to_dynamics: - phrases: - - переключи на динамики - - включи динамики - - перейди на динамики - - давай звук в динамики - - давай звук на динамики - - переключи звук в динамики - - переключи звук на динамики - - переключи на колонки - - включи колонки - - перейди на колонки - - давай звук в колонки - - давай звук на колонки - - переключи звук в колонки - - переключи звук на колонки - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to dynamics.exe", wait: true, delay_ms: 500} - - {type: play_sound, name: ready} - 'off': phrases: - выключись @@ -237,3 +855,947 @@ switch_to_dynamics: steps: - {type: play_sound, name: "off"} - {type: system, op: exit} + + +# ─── Ported from rust fork (2026-05-15) ─────────────────────────────────── + +lock_workstation: + phrases: + - заблокируй компьютер + - заблокируй пк + - заблокируй комп + - заблокируй экран + - запри компьютер + action: {type: lock_workstation} + +screenshot_clipboard: + phrases: + - сделай скриншот + - сделай скрин + - скопируй экран + - скриншот в буфер + - захвати экран + action: {type: screenshot, mode: clipboard} + +screenshot_file: + phrases: + - сохрани скриншот + - сохрани скрин + - скриншот в файл + - сохрани экран + action: {type: screenshot, mode: file} + +disk_free: + phrases: + - сколько свободного места + - сколько места на диске + - сколько свободно на диске + - место на диске + - свободное место + action: {type: disk_free} + +disk_list: + phrases: + - какие у меня диски + - список дисков + - перечень дисков + action: {type: disk_list} + +coin_flip: + phrases: + - подбрось монету + - брось монетку + - орёл или решка + - монетка + action: {type: coin_flip} + +password_gen: + phrases: + - сгенерируй пароль + - придумай пароль + - сгенерируй надёжный пароль + action: {type: password_gen} + +uuid_gen: + phrases: + - сгенерируй uuid + - сгенерируй юид + - юид + - уникальный идентификатор + action: {type: uuid_gen} + +ssl_check: + phrases: + - проверь сертификат + - когда истекает сертификат + - проверь tls + - проверь ssl + - сертификат сайта + action: {type: ssl_check} + +wifi_ssid: + phrases: + - к какой сети я подключен + - какой wifi + - какой вайфай + - какая сеть + - имя сети + action: {type: wifi_ssid} + +public_ip: + phrases: + - мой внешний ip + - публичный ip + - внешний айпи + - какой у меня внешний + action: {type: public_ip} + +convert_length: + phrases: + - переведи метры в футы + - переведи футы в метры + - сколько метров + - сколько футов + - сколько километров + - сколько миль + - переведи в метры + - переведи в футы + - переведи в км + - переведи в мили + action: {type: unit_convert, mode: length} + +convert_weight: + phrases: + - переведи килограммы в фунты + - переведи фунты в килограммы + - сколько кг в фунтах + - сколько фунтов + - сколько килограммов + action: {type: unit_convert, mode: weight} + +convert_temperature: + phrases: + - переведи цельсий в фаренгейт + - переведи фаренгейт в цельсий + - сколько по фаренгейту + - сколько по цельсию + action: {type: unit_convert, mode: temperature} + +convert_speed: + phrases: + - переведи километры в час в мили в час + - переведи в кмч + - переведи в мили в час + - сколько миль в час + - сколько км в час + action: {type: unit_convert, mode: speed} + +days_until: + phrases: + - сколько дней до + - сколько до нового года + - сколько до + - до нового года + action: {type: date_math, mode: days_until} + +day_of_week: + phrases: + - какой сегодня день недели + - какой день недели + - сегодня какой + action: {type: date_math, mode: day_of_week} + +echo_repeat: + phrases: + - повтори за мной + - скажи как я + - эхо + - проверка эхо + action: {type: echo_repeat} + +self_ping: + phrases: + - ты тут + - ты слышишь + - ты меня слышишь + - пинг + - ты живой + - ты на месте + - проверка связи + action: {type: self_ping} + +interesting_fact: + phrases: + - расскажи интересный факт + - интересный факт о + - интересный факт про + - удиви меня + - интересный факт + - расскажи факт + action: {type: interesting_fact} + +memory_remember: + phrases: + - запомни обо мне + - запомни что + - помни что + - запомни это + - запомни про меня + action: {type: memory_remember} + +memory_recall: + phrases: + - что ты помнишь о + - что ты знаешь обо мне + - что помнишь про + - вспомни + action: {type: memory_recall} + +memory_forget: + phrases: + - забудь про + - забудь что + - забудь обо мне + action: {type: memory_forget} + +memory_list: + phrases: + - что ты помнишь + - что ты знаешь обо мне всё + - покажи память + action: {type: memory_list} + +profile_work: + phrases: + - режим работа + - включи режим работы + - рабочий режим + - переключись в работу + action: {type: profile_set, target: work} + +profile_game: + phrases: + - режим игра + - игровой режим + - включи игровой режим + - переключись в игры + action: {type: profile_set, target: game} + +profile_sleep: + phrases: + - режим сон + - ночной режим + - режим тишины + - иди спать + - выключи всё + action: {type: profile_set, target: sleep} + +profile_driving: + phrases: + - режим за рулём + - режим вождения + - я за рулём + - поехали + action: {type: profile_set, target: driving} + +profile_default: + phrases: + - обычный режим + - режим по умолчанию + - сбрось режим + - вернись в обычный + action: {type: profile_set, target: default} + +profile_what: + phrases: + - какой сейчас режим + - в каком ты режиме + - текущий режим + - какой режим + action: {type: profile_what} + +vision_describe: + phrases: + - что на экране + - опиши экран + - посмотри на экран + - что у меня на экране + - посмотри на мой экран + - глянь на экран + action: {type: vision_describe} + +vision_read_error: + phrases: + - прочитай ошибку + - что за ошибка + - помоги с ошибкой + action: {type: vision_read_error} + +macros_start: + phrases: + - запиши макрос + - начни запись макроса + - записывай макрос + - новый макрос + action: {type: macros_start} + +macros_save: + phrases: + - сохрани макрос + - стоп макрос + - стоп запись макроса + - закончи запись + - макрос готов + action: {type: macros_save} + +macros_cancel: + phrases: + - отмени макрос + - отмени запись макроса + - забудь макрос + action: {type: macros_cancel} + +macros_replay: + phrases: + - запусти макрос + - выполни макрос + - воспроизведи макрос + - сыграй макрос + - проиграй макрос + action: {type: macros_replay} + +macros_list: + phrases: + - какие у меня макросы + - список макросов + - покажи макросы + action: {type: macros_list} + +macros_delete: + phrases: + - удали макрос + - забудь о макросе + action: {type: macros_delete} + +scheduler_reminder: + phrases: + - напомни мне через + - напомни через + - поставь напоминалку через + - поставь напоминание через + - разбуди через + action: {type: scheduler_add_reminder} + +scheduler_recurring: + phrases: + - напоминай каждые + - напоминай каждый + - каждые + - каждый час + action: {type: scheduler_add_recurring} + +scheduler_list: + phrases: + - что у меня запланировано + - какие напоминания + - покажи расписание + - какие задачи у меня + - что в расписании + action: {type: scheduler_list} + +scheduler_clear: + phrases: + - очисти расписание + - удали все напоминания + - отмени все напоминания + - сбрось расписание + action: {type: scheduler_clear} + +scheduler_cancel_text: + phrases: + - отмени напоминание про + - отмени напоминание о + - удали задачу про + - удали задачу о + - отмени про + - забудь напоминание про + action: {type: scheduler_cancel_by_text} + +codebase_set: + phrases: + - укажи проект + - укажи папку проекта + - укажи кодовую базу + - выбери проект + - проект сейчас + action: {type: codebase_set} + +codebase_where: + phrases: + - какой проект сейчас + - где проект + - какая папка проекта + - какая кодовая база + action: {type: codebase_where} + +codebase_ask: + phrases: + - что делает функция + - спроси код + - вопрос по коду + - что в коде + - найди в коде + - найди в проекте + - объясни код + action: {type: codebase_ask} + +github_set_repo: + phrases: + - текущий репо + - установи репо + - выбери репо + - репозиторий + action: {type: github_set_repo} + +github_list_prs: + phrases: + - какие пиары + - какие пулл реквесты + - что в пиарах + - что в pr + - открытые пиары + - список пиаров + action: {type: github_list_prs} + +github_summarize_pr: + phrases: + - разбери последний пиар + - объясни последний пиар + - обзор последнего пиара + - что в последнем pr + - разбор pr + action: {type: github_summarize_pr} + +llm_switch_local: + phrases: + - переключись на локальный + - переключись на локальный мозг + - включи локальный режим + - перейди на локальный + - используй локальный + - перейди на оллама + - используй оллама + action: {type: llm_switch, target: ollama} + +llm_switch_cloud: + phrases: + - переключись на облако + - переключись на облачный + - переключись на грок + - переключись на гроку + - используй облако + - используй грок + - перейди на облако + action: {type: llm_switch, target: groq} + +llm_status: + phrases: + - какой у тебя мозг + - какой ллм + - какой у тебя llm + - облако или локально + - ты сейчас где работаешь + - что за модель + action: {type: llm_status} + +now_playing: + phrases: + - что играет + - что сейчас играет + - что за музыка + - что за песня + - какой трек + - какая песня + - что слушаю + action: {type: now_playing} + +world_clock: + phrases: + - сколько времени в + - который час в + - сколько часов в + - время в + action: {type: world_clock} + +daily_quote: + phrases: + - цитата дня + - мотивирующая цитата + - скажи цитату + - вдохнови меня + - цитата + action: {type: daily_quote} + +magic_8ball: + phrases: + - ответь да или нет + - магический шар + - восьмой шар + - скажи да или нет + - что скажешь + - предсказание + action: {type: magic_8ball} + +gh_list_issues: + phrases: + - какие issues + - какие задачи + - открытые issues + - список issues + - что в issues + - тикеты + action: {type: gh_list_issues} + +gh_my_issues: + phrases: + - мои issues + - что мне назначено + action: {type: gh_my_issues} + +weather_tomorrow: + phrases: + - погода завтра + - что с погодой завтра + - прогноз на завтра + action: {type: weather_tomorrow} + +weather_week: + phrases: + - прогноз на неделю + - погода на неделю + - недельный прогноз + action: {type: weather_week} + +rss_add: + phrases: + - добавь rss + - подпишись на + - запомни ленту + - добавь ленту + action: {type: rss_add} + +rss_read: + phrases: + - что в ленте + - новости из ленты + - почитай rss + - новости из rss + - новые статьи + action: {type: rss_read} + +rss_list: + phrases: + - какие у меня rss + - список лент + - мои подписки + action: {type: rss_list} + +ics_create: + phrases: + - добавь встречу + - создай событие + - создай встречу + - новая встреча + action: {type: ics_create} + +backup_export: + phrases: + - сделай бекап + - экспортируй настройки + - сохрани мои настройки + - выгрузи настройки + action: {type: backup_export} + +clip_save: + phrases: + - запиши буфер + - сохрани буфер + - запомни буфер + - запомни что в буфере + action: {type: clip_save} + +clip_list: + phrases: + - что я копировал + - история буфера + - последние буферы + - что было в буфере + action: {type: clip_list} + +clip_restore: + phrases: + - верни первый буфер + - верни второй буфер + - верни третий буфер + - восстанови буфер + - вставь старый буфер + action: {type: clip_restore} + +notif_missed: + phrases: + - что я пропустил + - что было пока меня не было + - какие уведомления + - пропущенные + action: {type: notif_missed} + +notif_clear: + phrases: + - очисти уведомления + - забудь уведомления + - сбрось пропущенное + action: {type: notif_clear} + +vault_save: + phrases: + - сохрани пароль от + - запомни пароль от + - пароль для + action: {type: vault_save} + +vault_get: + phrases: + - пароль от + - дай пароль + - покажи пароль от + - достань пароль + action: {type: vault_get} + +vault_list: + phrases: + - какие у меня пароли + - список паролей + - что в хранилище паролей + action: {type: vault_list} + +habit_checkin: + phrases: + - отметь привычку + - выполнил привычку + - я попил воды + - я размялся + - я отдохнул глазам + action: {type: habit_checkin} + +habit_streak: + phrases: + - сколько дней подряд + - мои привычки + - статистика привычек + - сколько дней пью воду + - трекинг привычек + action: {type: habit_streak} + +personality_greet: + phrases: + - привет джарвис + - здравствуй джарвис + - доброе утро + - добрый день + - добрый вечер + - доброй ночи + - приветствую + - хай джарвис + action: {type: personality_greet} + +personality_thanks: + phrases: + - спасибо + - спасибо джарвис + - благодарю + - благодарю джарвис + - спс + - ты лучший + action: {type: personality_thanks} + +personality_compliment: + phrases: + - ты молодец + - ты умница + - молодец джарвис + - хорошо сработано + - отличная работа + - так держать + - ты лучший ассистент + action: {type: personality_compliment} + +personality_how_are_you: + phrases: + - как дела + - как дела джарвис + - как ты + - как настроение + - как самочувствие + - как поживаешь + - что нового у тебя + action: {type: personality_how_are_you} + +personality_tony_quote: + phrases: + - процитируй тони + - цитата тони старка + - что сказал бы тони + - скажи как тони + - процитируй железного человека + - цитата старка + action: {type: personality_tony_quote} + +tracker_start: + phrases: + - начни отсчёт + - начни отсчет + - запусти трекер + - я начинаю работу + - начало рабочего дня + action: {type: tracker_start} + +tracker_stop: + phrases: + - закончи отсчёт + - закончи отсчет + - останови трекер + - я закончил работу + - конец рабочего дня + action: {type: tracker_stop} + +tracker_today: + phrases: + - сколько я работаю сегодня + - сколько отработал сегодня + - сколько часов сегодня + - сколько времени за сегодня + action: {type: tracker_today} + +tracker_week: + phrases: + - сколько на этой неделе + - сколько отработал на неделе + - часы за неделю + - статистика за неделю + action: {type: tracker_week} + +tracker_reset: + phrases: + - сбрось трекер + - обнули трекер + - очисти трекер + - сбрось отсчёт + action: {type: tracker_reset} + +outlook_unread_count: + phrases: + - сколько у меня непрочитанных писем + - сколько непрочитанных писем + - проверь почту + - проверь outlook + - сколько новых писем + action: {type: outlook_unread_count} + +outlook_latest: + phrases: + - прочитай последнее письмо + - что в последнем письме + - последнее письмо + - покажи последнее письмо + action: {type: outlook_latest} + +outlook_send_clipboard: + phrases: + - отправь письмо + - отправь почту + - напиши письмо + - отправь это + action: {type: outlook_send_clipboard} + +outlook_summarize_inbox: + phrases: + - коротко расскажи про инбокс + - расскажи что в инбоксе + - сделай саммари инбокса + - что нового в почте + action: {type: outlook_summarize_inbox} + +memory_count: + phrases: + - сколько фактов помнишь + - сколько ты помнишь + - сколько у тебя в памяти + - размер памяти + action: {type: memory_count} + +memory_list: + phrases: + - выгрузи список фактов + - покажи что помнишь + - перечисли что помнишь + - что у тебя в памяти + action: {type: memory_list} + +memory_wipe: + phrases: + - точно забудь все + - точно забудь всё + - сотри всю память + action: {type: memory_wipe} + +cooking_timer: + phrases: + - поставь чайник + - варю яйца + - варю макароны + - варю пасту + - жарю омлет + - жарю яичницу + - варю кашу + - варю рис + - варю гречку + - варю картошку + - запекаю курицу + - пеку пиццу + - завариваю чай + - завариваю кофе + - завариваю френч-пресс + action: {type: cooking} + +leftoff: + phrases: + - на чём я остановился + - на чем я остановился + - напомни что делал + - что я делал + - где я остановился + - продолжи откуда я + action: {type: leftoff} + +trivia_iron_man: + phrases: + - расскажи про железного человека + - факт про железного человека + - интересное про старка + - марвел факт + action: {type: trivia} + +routines_good_night: + phrases: + - спокойной ночи + - иду спать + - пока я в кровати + - ночной режим + - выключи всё на ночь + action: {type: good_night} + +routines_good_morning: + phrases: + - доброе утро джарвис + - доброе утро + - я проснулся + - начинаем день + action: {type: good_morning} + +routines_coffee_break: + phrases: + - иду за кофе + - перерыв на кофе + - перерыв на чай + - отойду минут на пять + action: {type: coffee_break} + +expenses_log: + phrases: + - потратил + - записать трату + - запиши трату + - купил за + - заплатил за + action: {type: expenses_log} + +expenses_today: + phrases: + - сколько я потратил сегодня + - траты сегодня + - сколько ушло сегодня + action: {type: expenses_today} + +expenses_week: + phrases: + - сколько я потратил на этой неделе + - траты на неделе + - недельные траты + action: {type: expenses_week} + +expenses_breakdown: + phrases: + - куда ушли деньги + - разбивка по тратам + - на что я тратил + - разбивка по категориям + action: {type: expenses_breakdown} + +wol_wake: + phrases: + - разбуди сервер + - включи сервер + - разбуди машину + - разбуди компьютер + - включи компьютер + action: {type: wol} + +banter_fire: + phrases: + - скажи что-нибудь интересное + - пошути + - развлеки меня + - скучно мне + action: {type: banter_fire} + +banter_pause: + phrases: + - помолчи + - не отвлекай меня + - выключи болтовню + action: {type: banter_pause} + +banter_resume: + phrases: + - можешь говорить + - включи болтовню + - верни комментарии + action: {type: banter_resume} + +conversation_summary: + phrases: + - о чём мы говорили + - о чем мы говорили + - напомни о чем мы говорили + - что мы недавно обсуждали + - сделай выжимку разговора + action: {type: conversation_summary} + +conversation_repeat: + phrases: + - повтори + - повтори последнее + - что ты сказал + - не расслышал + action: {type: conversation_repeat} + +ddg_answer: + phrases: + - что такое + - кто такой + - кто такая + - расскажи про + - расскажи о + - найди информацию о + - поищи в интернете + action: {type: ddg_answer} diff --git a/config.py b/config.py index b78f82c..e7af1c9 100644 --- a/config.py +++ b/config.py @@ -31,6 +31,12 @@ GROQ_TOKEN = os.getenv('GROQ_TOKEN') GROQ_BASE_URL = "https://api.groq.com/openai/v1" GROQ_MODEL = "llama-3.3-70b-versatile" +# LLM auto-fallback: route unrecognized commands straight to Groq instead of +# playing the "not_found" sound. The old "скажи X" trigger still works (TBR +# strips it before intent matching, so what remains hits this fallback anyway). +LLM_AUTO_FALLBACK = True +LLM_AUTO_FALLBACK_MIN_CHARS = 4 + # VAD (webrtcvad) — определяет конец команды по тишине вместо фиксированных 10 сек. # 0..3, выше — агрессивнее режет шум (но и обрезает речь). VAD_AGGRESSIVENESS = 2 diff --git a/dev_handlers.py b/dev_handlers.py new file mode 100644 index 0000000..9318698 --- /dev/null +++ b/dev_handlers.py @@ -0,0 +1,313 @@ +"""Developer-oriented handlers — port of `codebase_qa/` and `github_pr/` packs. + +Codebase Q&A: point Jarvis at a folder, ask questions; he walks source files +(depth-limited, size-capped) and feeds a digest to the LLM. + +GitHub PR review: requires the `gh` CLI authenticated. Lists open PRs or +reviews the latest one via LLM. + +Both need GROQ_TOKEN (or a configured OpenAI-compat endpoint). +""" + +import json +import os +import re +import subprocess + +import memory_store +import llm_backend + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[dev] speak: {exc}") + + +# ── codebase Q&A ──────────────────────────────────────────────────────────── + +_EXT_OK = { + 'rs', 'lua', 'py', 'ts', 'tsx', 'js', 'jsx', 'svelte', + 'go', 'java', 'kt', 'c', 'h', 'cpp', 'hpp', 'cs', + 'rb', 'php', 'sh', 'ps1', 'sql', 'toml', 'yaml', 'yml', + 'json', 'md', +} + +_SKIP_DIR = { + '.git', 'target', 'node_modules', 'dist', 'build', + '__pycache__', '.venv', 'venv', '.idea', '.vscode', +} + +_MAX_FILE_BYTES = 4096 +_MAX_TOTAL_BYTES = 50_000 +_MAX_FILES = 30 +_MAX_DEPTH = 3 + + +def _walk_codebase(root: str) -> list[tuple[str, str]]: + """Return list of (relative_path, content) tuples — capped per the constants above.""" + chunks = [] + total_bytes = 0 + root = os.path.normpath(root) + if not os.path.isdir(root): + return chunks + + for dirpath, dirnames, filenames in os.walk(root): + # Depth gate + rel = os.path.relpath(dirpath, root) + depth = 0 if rel == '.' else rel.count(os.sep) + 1 + if depth > _MAX_DEPTH: + dirnames[:] = [] + continue + # Prune skip dirs in-place + dirnames[:] = [d for d in dirnames + if d not in _SKIP_DIR and not d.startswith('.')] + + for fname in filenames: + if len(chunks) >= _MAX_FILES or total_bytes >= _MAX_TOTAL_BYTES: + return chunks + _, dot_ext = os.path.splitext(fname) + ext = (dot_ext or '').lstrip('.').lower() + if ext not in _EXT_OK: + continue + fp = os.path.join(dirpath, fname) + try: + with open(fp, 'r', encoding='utf-8', errors='replace') as fh: + content = fh.read(_MAX_FILE_BYTES + 1) + except (OSError, ValueError): + continue + if len(content) > _MAX_FILE_BYTES: + content = content[:_MAX_FILE_BYTES] + "\n... [truncated]" + rel_path = os.path.relpath(fp, root) + chunks.append((rel_path, content)) + total_bytes += len(content) + return chunks + + +def do_codebase_set(action, voice): + """'Укажи проект C:\\Jarvis\\rust' — stores root via memory.""" + body = (voice or '').strip() + low = body.lower() + for trig in ('укажи папку проекта', 'укажи кодовую базу', + 'укажи проект', 'выбери проект', 'проект сейчас'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + if not body: + _speak("Укажите путь к папке проекта.") + return + if not os.path.isdir(body): + _speak(f"Папка не найдена: {body}.") + return + memory_store.remember("codebase.root", body) + _speak("Проект установлен.") + + +def do_codebase_where(action, voice): + root = memory_store.recall("codebase.root") + if not root: + _speak("Проект не выбран. Скажите: укажи проект и путь.") + return + _speak(f"Сейчас работаю с проектом {root}.") + + +def do_codebase_ask(action, voice): + """'Что делает функция X' / 'найди в коде Y'.""" + root = memory_store.recall("codebase.root") + if not root: + _speak("Сначала укажите проект.") + return + + body = (voice or '').strip() + low = body.lower() + for trig in ('что делает функция', 'вопрос по коду', + 'спроси код', 'найди в проекте', + 'найди в коде', 'что в коде', 'объясни код'): + idx = low.find(trig) + if idx >= 0: + body = body[idx + len(trig):].strip(' ,.:') + break + + if not body: + _speak("Сформулируйте вопрос.") + return + + chunks = _walk_codebase(root) + if not chunks: + _speak("Не нашёл исходников в проекте.") + return + + digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks) + + client = llm_backend.current_client() + if client is None: + _speak("LLM не настроен.") + return + model = llm_backend.current_model() + + user_prompt = ( + f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя " + f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n" + f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}" + ) + + try: + resp = client.chat.completions.create( + model=model, + messages=[ + {'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'}, + {'role': 'user', 'content': user_prompt}, + ], + max_tokens=400, temperature=0.2, timeout=45, + ) + answer = resp.choices[0].message.content.strip() + except Exception as exc: + print(f"[dev] codebase LLM: {exc}") + _speak("Не получилось получить ответ.") + return + + if not answer: + _speak("Пустой ответ.") + return + _speak(answer) + + +# ── github PR ─────────────────────────────────────────────────────────────── + +def _gh(*args, timeout=15): + """Run gh CLI, return (success, stdout, stderr).""" + try: + res = subprocess.run( + ['gh', *args], + capture_output=True, text=True, encoding='utf-8', + timeout=timeout, + ) + return res.returncode == 0, res.stdout, res.stderr + except FileNotFoundError: + return False, '', 'gh CLI not installed' + except Exception as exc: + return False, '', str(exc) + + +def do_github_set_repo(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in ('установи репо', 'выбери репо', 'текущий репо', 'репозиторий'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + if not body or '/' not in body: + _speak("Скажите owner slash repo, например bossiara13 slash J A R V I S.") + return + memory_store.remember("github.repo", body) + _speak(f"Репозиторий {body} установлен.") + + +def do_github_list_prs(action, voice): + repo = memory_store.recall("github.repo") + if not repo: + _speak("Сначала укажите репозиторий: текущий репо owner/repo.") + return + + ok, out, err = _gh('pr', 'list', '--repo', repo, '--state', 'open', + '--json', 'number,title,author', '--limit', '10') + if not ok: + print(f"[dev] gh list: {err[:200]}") + _speak("gh CLI не отвечает. Установите gh и выполните gh auth login.") + return + + try: + prs = json.loads(out or '[]') + except json.JSONDecodeError: + prs = [] + if not prs: + _speak("Открытых пиаров нет.") + return + + line = f"{len(prs)} открытых пиаров. " + titles = [p.get('title', '') for p in prs[:3] if p.get('title')] + if titles: + line += "Первые: " + ". ".join(titles) + "." + _speak(line) + + +def do_github_summarize_pr(action, voice): + repo = memory_store.recall("github.repo") + if not repo: + _speak("Сначала укажите репозиторий.") + return + + # Most recent open PR number + ok, out, _ = _gh('pr', 'list', '--repo', repo, '--state', 'open', + '--json', 'number', '--limit', '1') + if not ok: + _speak("gh CLI не отвечает.") + return + try: + items = json.loads(out or '[]') + number = items[0]['number'] if items else None + except (json.JSONDecodeError, KeyError, IndexError): + number = None + if not number: + _speak("Открытых пиаров нет.") + return + + ok, out, _ = _gh('pr', 'view', str(number), '--repo', repo, + '--json', 'title,body,additions,deletions,changedFiles,author') + if not ok: + _speak("Не получилось получить PR.") + return + try: + pr = json.loads(out) + except json.JSONDecodeError: + _speak("gh вернул битый JSON.") + return + + title = pr.get('title', '(no title)') + body = (pr.get('body') or '')[:3000] + additions = pr.get('additions', '?') + deletions = pr.get('deletions', '?') + files = pr.get('changedFiles', '?') + author = (pr.get('author') or {}).get('login', '(unknown)') + + client = llm_backend.current_client() + if client is None: + _speak(f"PR номер {number}: {title}. Без LLM — настройте Groq или Ollama.") + return + model = llm_backend.current_model() + + user_prompt = ( + f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n" + f"Changed files: {files} (+{additions}, -{deletions})\n" + f"Description:\n{body}\n\n" + "Кратко (3-5 предложений) на русском: что меняет этот PR, " + "какие риски, стоит ли мёржить." + ) + + try: + resp = client.chat.completions.create( + model=model, + messages=[ + {'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."}, + {'role': 'user', 'content': user_prompt}, + ], + max_tokens=400, temperature=0.2, timeout=45, + ) + review = resp.choices[0].message.content.strip() + except Exception as exc: + print(f"[dev] PR LLM: {exc}") + _speak("Не получилось проанализировать.") + return + + if not review: + _speak("Пустая сводка.") + return + _speak(f"Пиар номер {number}: {review}") diff --git a/expenses_handlers.py b/expenses_handlers.py new file mode 100644 index 0000000..81612e9 --- /dev/null +++ b/expenses_handlers.py @@ -0,0 +1,177 @@ +"""Personal finance tracker — Python parity for the Rust `expenses/` pack. + +Stores entries in `/expenses.json` (atomic write-through), exposes +the same four voice entry points as the Rust pack: + + do_expenses_log "потратил 500 на еду" + do_expenses_today "сколько потратил сегодня" + do_expenses_week "сколько на неделе" + do_expenses_breakdown "куда ушли деньги" + +State shape: { "entries": [{"ts": int, "amount": float, "category": str}, ...] } +""" + +import json +import os +import re +import time as _time +from typing import Callable, Optional + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'expenses.json') + +_speak: Optional[Callable[[str], None]] = None + + +def set_speak(fn: Callable[[str], None]) -> None: + global _speak + _speak = fn + + +def _say(text: str) -> None: + if _speak is not None: + _speak(text) + else: + print(f"[expenses] {text}") + + +def load() -> dict: + """Return current state. Empty-shape dict if file missing or unreadable.""" + if not os.path.isfile(_PATH): + return {'entries': []} + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict) or not isinstance(data.get('entries'), list): + return {'entries': []} + return data + except (OSError, json.JSONDecodeError): + return {'entries': []} + + +def _save(data: dict) -> None: + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as e: + print(f"[expenses] save failed: {e}") + + +# Pulled out so tests can exercise parsing without going through TTS. +_TRIGGERS = [ + "потратил", "записать трату", "запиши трату", "купил за", "заплатил за", + "spent", "log expense", "paid for", "bought", +] + + +def _strip_trigger(phrase: str) -> str: + p = (phrase or "").lower().strip() + for t in _TRIGGERS: + if p.startswith(t): + return p[len(t):].strip() + return p + + +def parse_expense(phrase: str): + """Return (amount: float, category: str) or None if the phrase doesn't + contain a parseable expense. Pure function — used by both `do_expenses_log` + and the test suite.""" + rest = _strip_trigger(phrase) + m = re.match(r"(-?\d+[.,]?\d*)\s*(.*)", rest) + if not m: + return None + amount_str = m.group(1).replace(",", ".") + try: + amount = float(amount_str) + except ValueError: + return None + if amount <= 0: + return None + after = m.group(2) or "" + category = "разное" + # "на X" (RU) / "for X" / "on X" (EN) + cat_match = re.search(r"\bна\s+(\S+)", after) \ + or re.search(r"\bfor\s+(\S+)", after) \ + or re.search(r"\bon\s+(\S+)", after) + if cat_match: + category = cat_match.group(1).strip(".,!?") + return amount, category + + +def do_expenses_log(action=None, voice=None) -> None: + parsed = parse_expense(voice or "") + if parsed is None: + _say("Не понял сумму, сэр.") + return + amount, category = parsed + data = load() + data['entries'].append({ + 'ts': int(_time.time()), + 'amount': amount, + 'category': category, + }) + _save(data) + _say(f"Записал: {amount:g} на {category}.") + + +def _today_bounds() -> tuple[int, int]: + now = _time.localtime() + start = _time.mktime((now.tm_year, now.tm_mon, now.tm_mday, + 0, 0, 0, 0, 0, -1)) + return int(start), int(start) + 86400 + + +def do_expenses_today(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("Сегодня ничего не тратили, сэр.") + return + start, end = _today_bounds() + total = sum(e['amount'] for e in data['entries'] + if isinstance(e.get('ts'), int) and start <= e['ts'] < end) + if total == 0: + _say("Сегодня ничего не тратили, сэр.") + else: + _say(f"Сегодня потратили {total:g}.") + + +def do_expenses_week(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("На этой неделе ничего не тратили.") + return + cutoff = int(_time.time()) - 7 * 86400 + rows = [e for e in data['entries'] + if isinstance(e.get('ts'), int) and e['ts'] >= cutoff] + if not rows: + _say("На этой неделе тратами не похваляешься, сэр.") + return + total = sum(e['amount'] for e in rows) + _say(f"За неделю {len(rows)} трат на сумму {total:g}.") + + +def do_expenses_breakdown(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("Нечего разбивать, сэр.") + return + cutoff = int(_time.time()) - 7 * 86400 + by_cat: dict[str, float] = {} + for e in data['entries']: + if isinstance(e.get('ts'), int) and e['ts'] >= cutoff: + cat = e.get('category', 'разное') + by_cat[cat] = by_cat.get(cat, 0.0) + e['amount'] + if not by_cat: + _say("На этой неделе тишина по тратам, сэр.") + return + rows = sorted(by_cat.items(), key=lambda kv: kv[1], reverse=True) + top = rows[:5] + rest = rows[5:] + parts = [f"{cat} {amt:g}" for cat, amt in top] + tail = "" + if rest: + rest_total = sum(amt for _, amt in rest) + tail = f" и другое {rest_total:g}" + _say(f"По категориям: {', '.join(parts)}{tail}.") diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..948e248 --- /dev/null +++ b/extensions.py @@ -0,0 +1,1191 @@ +"""Extension handlers — new commands ported from the Rust fork. + +Each function takes (action, voice) and returns nothing; it speaks via the +`speak` callback passed in from main.py. We keep the speech callback as +a module global to avoid passing it through every call site. + +Usage from main.py: + import extensions + extensions.set_speak(_speak) + # then dispatch: + if t == 'lock_workstation': extensions.do_lock(action, voice) +""" + +import os +import math +import random +import subprocess +import time +import uuid as uuidlib +import re +import urllib.request + +import memory_store +import profiles_store +import macros_store +import scheduler_store +import vision_handler +import dev_handlers +import llm_backend +import wave1_handlers +import personality_handlers +import time_tracker_handlers +import outlook_handlers +import wave68_handlers +import expenses_handlers +import wave59_handlers + +_speak_fn = print +_set_clipboard_fn = None + + +def set_speak(fn): + """Register the main.py _speak function as our voice output.""" + global _speak_fn + _speak_fn = fn + scheduler_store.set_speak(fn) + vision_handler.set_speak(fn) + dev_handlers.set_speak(fn) + wave1_handlers.set_speak(fn) + personality_handlers.set_speak(fn) + time_tracker_handlers.set_speak(fn) + outlook_handlers.set_speak(fn) + wave68_handlers.set_speak(fn) + expenses_handlers.set_speak(fn) + wave59_handlers.set_speak(fn) + + +def set_clipboard_fn(fn): + """Register the main.py _set_clipboard helper.""" + global _set_clipboard_fn + _set_clipboard_fn = fn + + +def init_background_services(): + """Boot scheduler. Idempotent. Called once from main.py at startup.""" + scheduler_store.init() + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[ext] speak failed: {exc}") + + +def _clip(text): + if _set_clipboard_fn: + try: + _set_clipboard_fn(text) + except Exception as exc: + print(f"[ext] clipboard set failed: {exc}") + + +def _ps(cmd, timeout=15): + """Run a PowerShell command, return stripped stdout.""" + res = subprocess.run( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cmd], + capture_output=True, text=True, encoding='utf-8', timeout=timeout, + ) + return (res.stdout or '').strip() + + +# ── lock_workstation ─────────────────────────────────────────────────────── + +def do_lock_workstation(action, voice): + """Lock the workstation via LockWorkStation() in user32.dll.""" + subprocess.Popen(['rundll32.exe', 'user32.dll,LockWorkStation']) + _speak("Компьютер заблокирован.") + + +# ── screenshot ───────────────────────────────────────────────────────────── + +_SHOT_TO_CLIP_PS = ( + "Add-Type -AssemblyName System.Windows.Forms; " + "Add-Type -AssemblyName System.Drawing; " + "$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; " + "$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; " + "$g = [System.Drawing.Graphics]::FromImage($bmp); " + "$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size); " + "[System.Windows.Forms.Clipboard]::SetImage($bmp); " + "$g.Dispose(); $bmp.Dispose()" +) + + +def do_screenshot(action, voice): + mode = action.get('mode', 'clipboard') + if mode == 'clipboard': + try: + subprocess.run( + ['powershell', '-NoProfile', '-Command', _SHOT_TO_CLIP_PS], + capture_output=True, timeout=10, check=True, + ) + _speak("Скриншот в буфере.") + except Exception as exc: + print(f"[ext] screenshot clipboard failed: {exc}") + _speak("Не получилось сделать скриншот.") + elif mode == 'file': + userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public') + screens_dir = os.path.join(userprofile, 'Pictures', 'Screenshots') + os.makedirs(screens_dir, exist_ok=True) + ts = time.strftime('%Y%m%d-%H%M%S') + path = os.path.join(screens_dir, f'jarvis-{ts}.png') + ps = ( + "Add-Type -AssemblyName System.Windows.Forms; " + "Add-Type -AssemblyName System.Drawing; " + "$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; " + "$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; " + "$g = [System.Drawing.Graphics]::FromImage($bmp); " + "$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size); " + f"$bmp.Save('{path}', [System.Drawing.Imaging.ImageFormat]::Png); " + "$g.Dispose(); $bmp.Dispose()" + ) + try: + subprocess.run(['powershell', '-NoProfile', '-Command', ps], + capture_output=True, timeout=10, check=True) + _speak("Скриншот сохранён в папку Скриншоты.") + except Exception as exc: + print(f"[ext] screenshot file failed: {exc}") + _speak("Не получилось сохранить скриншот.") + + +# ── disk space ───────────────────────────────────────────────────────────── + +def do_disk_free(action, voice): + """Reports free GB on the requested drive (defaults to C).""" + letter = (action.get('letter') or 'C').upper() + # Pull letter from voice if user named one + m = re.search(r'диск[еа]?\s*([A-ZА-Я])', (voice or '').upper()) + if m: + letter = m.group(1) + # cyrillic Е/А fallback to C + if letter not in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': + letter = 'C' + + out = _ps( + f"$d = Get-PSDrive {letter} -ErrorAction SilentlyContinue; " + "if ($d) {{ '{{0}}|{{1}}' -f " + "[math]::Round($d.Free/1GB,1), " + "[math]::Round(($d.Free + $d.Used)/1GB,0) }} else {{ 'NONE' }}".format( + letter=letter + ) + ) + if out == 'NONE' or '|' not in out: + _speak(f"Диск {letter} не найден.") + return + free, total = out.split('|', 1) + try: + pct = int(round(float(free) / float(total) * 100)) + _speak(f"На диске {letter} свободно {free} гигабайт из {total}, это {pct} процентов.") + except (ValueError, ZeroDivisionError): + _speak(f"На диске {letter} свободно {free} гигабайт.") + + +def do_disk_list(action, voice): + out = _ps( + "Get-PSDrive -PSProvider FileSystem | " + "Where-Object { $_.Used -ne $null -or $_.Free -ne $null } | " + "ForEach-Object { '{0}:{1}' -f $_.Name, [math]::Round($_.Free/1GB,0) }" + ) + if not out: + _speak("Дисков не нашёл.") + return + parts = [] + for tok in out.split(): + m = re.match(r'([A-Z]):(\d+)', tok) + if m: + parts.append(f"{m.group(1)} {m.group(2)} гигабайт") + if not parts: + _speak("Не понял ответ системы.") + return + _speak("Свободно: " + ", ".join(parts) + ".") + + +# ── random generators ────────────────────────────────────────────────────── + +def do_coin(action, voice): + if random.randint(1, 2) == 1: + _speak("Орёл!") + else: + _speak("Решка!") + + +_PASS_POOL = ( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_=+' +) + + +def do_password_gen(action, voice): + m = re.search(r'(\d+)', voice or '') + n = int(m.group(1)) if m else 16 + n = max(6, min(64, n)) + password = ''.join(random.choice(_PASS_POOL) for _ in range(n)) + _clip(password) + _speak(f"Пароль на {n} символов в буфере.") + + +def do_uuid(action, voice): + u = str(uuidlib.uuid4()) + _clip(u) + _speak(f"UUID в буфере, заканчивается на {u[-4:]}.") + + +# ── SSL cert check ───────────────────────────────────────────────────────── + +def do_ssl_check(action, voice): + domain = action.get('domain') or _extract_domain(voice) + if not domain: + _speak("Какой домен проверить?") + return + domain = domain.replace('https://', '').replace('http://', '').split('/')[0] + ps = f""" +$ErrorActionPreference = 'Stop' +try {{ + $tcp = New-Object Net.Sockets.TcpClient('{domain}', 443) + $ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, {{ $true }}) + $ssl.AuthenticateAsClient('{domain}') + $c = New-Object Security.Cryptography.X509Certificates.X509Certificate2($ssl.RemoteCertificate) + $days = ($c.NotAfter - (Get-Date)).Days + Write-Output ('{{0}}|{{1}}' -f $days, $c.NotAfter.ToString('yyyy-MM-dd')) + $ssl.Close(); $tcp.Close() +}} catch {{ Write-Output ('ERR|' + $_.Exception.Message) }} +""" + out = _ps(ps) + if out.startswith('ERR|'): + _speak(f"Не получилось: {out[4:80]}") + return + if '|' not in out: + _speak("Не распарсил ответ.") + return + days, date = out.split('|', 1) + try: + days_n = int(days) + except ValueError: + _speak("Не понял ответ.") + return + prefix = '' + if days_n < 0: + prefix = "Сертификат уже просрочен! " + elif days_n < 7: + prefix = "СКОРО! " + elif days_n < 30: + prefix = "Скоро. " + _speak(f"{prefix}Сертификат {domain} истекает через {days} дней ({date}).") + + +def _extract_domain(voice): + if not voice: + return '' + # crude: pick last token that looks like a domain (has a dot) + for tok in voice.split(): + if '.' in tok and len(tok) > 3: + return tok + return '' + + +# ── WiFi SSID ────────────────────────────────────────────────────────────── + +def do_wifi_ssid(action, voice): + res = subprocess.run(['netsh', 'wlan', 'show', 'interfaces'], + capture_output=True, text=True, encoding='cp866', timeout=5) + out = res.stdout or '' + # Try ru + en field names + m = re.search(r'^\s*SSID\s+:\s+(.+?)$', out, re.MULTILINE) + if not m: + m = re.search(r'Имя сети\s+:\s+(.+?)$', out, re.MULTILINE) + if m: + ssid = m.group(1).strip() + if ssid in ('(none)', '(нет)'): + _speak("Wi-Fi не подключен.") + return + _speak(f"Сеть: {ssid}.") + else: + _speak("Wi-Fi не подключен или адаптер выключен.") + + +def do_public_ip(action, voice): + try: + with urllib.request.urlopen('https://api.ipify.org', timeout=5) as r: + ip = r.read().decode().strip() + if ip: + _speak(f"Внешний адрес: {ip}.") + else: + _speak("Сервис вернул пустой ответ.") + except Exception as exc: + print(f"[ext] public_ip failed: {exc}") + _speak("Не получилось узнать внешний IP.") + + +# ── unit conversion ──────────────────────────────────────────────────────── + +def _grab_number(voice): + m = re.search(r'(-?[\d.,]+)', voice or '') + if not m: + return None + try: + return float(m.group(1).replace(',', '.')) + except ValueError: + return None + + +def _ru_plural(n, one, few, many): + """Russian-grammar pluralisation: 1 фут, 2-4 фута, 5+ футов.""" + nn = abs(int(n)) + last_two = nn % 100 + last = nn % 10 + if 11 <= last_two <= 19: + return many + if last == 1: + return one + if 2 <= last <= 4: + return few + return many + + +def do_unit_convert(action, voice): + mode = action.get('mode', 'length') + n = _grab_number(voice) + if n is None: + _speak("Не понял число.") + return + low = (voice or '').lower() + if mode == 'length': + if 'фут' in low and 'в метр' not in low: + result = n * 3.28084 + unit = _ru_plural(result, 'фут', 'фута', 'футов') + elif 'метр' in low and 'в фут' not in low: + result = n / 3.28084 + unit = 'метров' + elif 'мил' in low and 'в км' not in low: + result = n * 0.621371 + unit = _ru_plural(result, 'миля', 'мили', 'миль') + elif 'км' in low or 'километр' in low: + result = n * 1.60934 + unit = 'километров' + else: + _speak("Не понял в чём перевести.") + return + _speak(f"{round(result, 2):g} {unit}.") + elif mode == 'weight': + if 'фунт' in low and 'в кг' not in low: + result = n * 2.20462 + unit = _ru_plural(result, 'фунт', 'фунта', 'фунтов') + else: + result = n / 2.20462 + unit = 'килограмм' + _speak(f"{round(result, 2):g} {unit}.") + elif mode == 'temperature': + if 'фаренгейт' in low or 'fahrenheit' in low: + result = n * 9 / 5 + 32 + unit = 'по Фаренгейту' + else: + result = (n - 32) * 5 / 9 + unit = 'по Цельсию' + _speak(f"{round(result, 1):g} градусов {unit}.") + elif mode == 'speed': + if 'мил' in low and 'в км' not in low: + result = n * 0.621371 + unit = 'миль в час' + else: + result = n * 1.60934 + unit = 'километров в час' + _speak(f"{int(round(result))} {unit}.") + else: + _speak(f"Неизвестный режим конверсии: {mode}.") + + +# ── date math ────────────────────────────────────────────────────────────── + +_MONTH_STEMS = { + 'январ': 1, 'феврал': 2, 'март': 3, 'апрел': 4, + 'мая': 5, 'май': 5, 'июн': 6, 'июл': 7, + 'август': 8, 'сентябр': 9, 'октябр': 10, 'ноябр': 11, 'декабр': 12, +} + +_DAYS_RU = [ + 'понедельник', 'вторник', 'среда', 'четверг', + 'пятница', 'суббота', 'воскресенье', +] + + +def do_date_math(action, voice): + import datetime as dt + mode = action.get('mode', 'days_until') + low = (voice or '').lower() + today = dt.date.today() + + if mode == 'day_of_week': + # weekday() returns 0=Monday..6=Sunday + _speak(f"Сегодня {_DAYS_RU[today.weekday()]}.") + return + + # days_until + target = None + if 'нов' in low and 'год' in low: + ny = today.year if (today.month == 1 and today.day == 1) else today.year + 1 + target = dt.date(ny, 1, 1) + elif 'рожд' in low: + target = dt.date(today.year, 1, 7) + elif '8 март' in low or 'восьмого март' in low: + target = dt.date(today.year, 3, 8) + elif '9 ма' in low or 'девятое ма' in low: + target = dt.date(today.year, 5, 9) + else: + m = re.search(r'до\s+(\d+)', low) + if m: + day = int(m.group(1)) + for stem, month in _MONTH_STEMS.items(): + if stem in low: + try: + target = dt.date(today.year, month, day) + except ValueError: + target = None + break + + if target is None: + _speak("Не понял дату.") + return + if target < today: + target = target.replace(year=target.year + 1) + diff = (target - today).days + if diff == 0: + _speak("Сегодня.") + return + unit = _ru_plural(diff, 'день', 'дня', 'дней') + _speak(f"{diff} {unit}.") + + +# ── echo / self-check ────────────────────────────────────────────────────── + +def do_echo_repeat(action, voice): + body = (voice or '').strip() + for trig in ('повтори за мной', 'скажи как я', 'эхо', 'repeat after me', 'echo'): + idx = body.lower().find(trig) + if idx == 0: + body = body[len(trig):].strip(' ,.:') + break + if not body: + _speak("Что повторить?") + return + _speak(body) + + +def do_self_ping(action, voice): + options = ['Да, сэр.', 'Здесь, сэр.', 'Слушаю.', 'К вашим услугам.', 'На месте.'] + _speak(random.choice(options)) + + +# ── memory ────────────────────────────────────────────────────────────────── + +_MEMORY_REMEMBER_TRIGGERS = ( + 'запомни обо мне что', 'запомни обо мне', 'запомни что', + 'помни что', 'запомни это', 'запомни про меня', 'запомни', +) + + +def do_memory_remember(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in _MEMORY_REMEMBER_TRIGGERS: + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + + if not body: + _speak("Что именно запомнить?") + return + + if '=' in body: + key, value = body.split('=', 1) + key, value = key.strip(), value.strip() + else: + words = body.split() + key = ' '.join(words[:6]) + value = body + + if not key: + _speak("Не понял ключ.") + return + + memory_store.remember(key, value) + _speak("Запомнил.") + + +def do_memory_recall(action, voice): + body = (voice or '').lower() + query = '' + for trig in ('что ты помнишь о', 'что ты помнишь про', 'что ты знаешь обо мне', + 'что помнишь про', 'вспомни'): + idx = body.find(trig) + if idx >= 0: + query = body[idx + len(trig):].strip(' ,.:') + break + + if not query: + recs = memory_store.all_facts() + if not recs: + _speak("Ничего пока не помню.") + return + line = "Я помню: " + for r in recs[:3]: + line += f"{r['key']} — {r['value']}. " + _speak(line) + return + + hits = memory_store.search(query, 3) + if not hits: + _speak(f"Ничего не нашёл про {query}.") + return + if len(hits) == 1: + _speak(hits[0]['value']) + else: + _speak('. '.join(f"{h['key']}: {h['value']}" for h in hits)) + + +def do_memory_forget(action, voice): + body = (voice or '').lower() + for trig in ('забудь про', 'забудь что', 'забудь обо мне'): + idx = body.find(trig) + if idx >= 0: + key = body[idx + len(trig):].strip(' ,.:') + if memory_store.forget(key): + _speak(f"Забыл про {key}.") + return + hits = memory_store.search(key, 1) + if hits: + memory_store.forget(hits[0]['key']) + _speak(f"Забыл про {hits[0]['key']}.") + return + _speak(f"Я и не помнил про {key}.") + return + _speak("Что забыть?") + + +def do_memory_list(action, voice): + recs = memory_store.all_facts() + if not recs: + _speak("Я пока ничего о вас не запомнил.") + return + sample = min(len(recs), 5) + line = f"Помню {len(recs)} фактов. Первые: " + line += ", ".join(r['key'] for r in recs[:sample]) + "." + _speak(line) + + +# ── profile switch ────────────────────────────────────────────────────────── + +def do_profile_set(action, voice): + target = action.get('target') or _profile_target_from_voice(voice) + if not target: + _speak("Не понял какой режим.") + return + try: + p = profiles_store.set_active(target) + except ValueError: + _speak(f"Профиль {target} не найден.") + return + greeting = p.get('greeting', '') or f"Режим {target}." + _speak(greeting) + + +def _profile_target_from_voice(voice): + low = (voice or '').lower() + if 'работ' in low: return 'work' + if 'игр' in low: return 'game' + if 'сон' in low or 'спать' in low or 'тиш' in low: return 'sleep' + if 'руль' in low or 'вожд' in low or 'поехали' in low: return 'driving' + if 'обычн' in low or 'умолчан' in low or 'сброс' in low or 'верн' in low: return 'default' + return '' + + +def do_profile_what(action, voice): + p = profiles_store.active() + name = p.get('name', 'default') + desc = p.get('description', '') + msg = f"Сейчас {name}." + if desc: + msg += " " + desc + _speak(msg) + + +# ── vision ────────────────────────────────────────────────────────────────── + +def do_vision_describe(action, voice): + vision_handler.do_vision_describe(action, voice) + + +def do_vision_read_error(action, voice): + vision_handler.do_vision_read_error(action, voice) + + +# ── macros ────────────────────────────────────────────────────────────────── + +def _strip_trig_lower(voice: str, triggers): + low = (voice or '').lower() + for t in triggers: + idx = low.find(t) + if idx >= 0: + return (voice or '')[idx + len(t):].strip(' ,.:') + return voice or '' + + +def do_macros_start(action, voice): + name = _strip_trig_lower(voice, ( + 'начни запись макроса', 'записывай макрос', + 'запиши макрос', 'новый макрос', + )).strip() + if not name: + _speak("Как назвать макрос?") + return + try: + macros_store.start(name) + except ValueError as exc: + _speak(f"Не получилось: {exc}.") + return + _speak(f"Записываю макрос {name}. Говорите команды. Когда закончите — скажите сохрани макрос.") + + +def do_macros_save(action, voice): + try: + count = macros_store.save() + except (RuntimeError,) as exc: + _speak(str(exc)) + return + _speak(f"Сохранил {count} шагов.") + + +def do_macros_cancel(action, voice): + if macros_store.cancel(): + _speak("Запись макроса отменена.") + else: + _speak("Записи не было.") + + +_DISPATCH_FN = None + + +def set_dispatch_fn(fn): + """Register the main.py `execute_cmd(matched_phrase, voice)` for replay.""" + global _DISPATCH_FN + _DISPATCH_FN = fn + + +def do_macros_replay(action, voice): + name = _strip_trig_lower(voice, ( + 'выполни макрос', 'воспроизведи макрос', + 'запусти макрос', 'сыграй макрос', 'проиграй макрос', + )).strip() + if not name: + _speak("Какой макрос запустить?") + return + if _DISPATCH_FN is None: + _speak("Replay не подключен.") + return + try: + count = macros_store.replay(name, _DISPATCH_FN) + except KeyError as exc: + _speak(str(exc)) + return + _speak(f"Запускаю {name}, шагов: {count}.") + + +def do_macros_list(action, voice): + names = macros_store.list_names() + if not names: + _speak("Макросов нет.") + return + sample = names[:5] + line = f"Макросов: {len(names)}. Первые: " + ", ".join(sample) + "." + _speak(line) + + +def do_macros_delete(action, voice): + name = _strip_trig_lower(voice, ( + 'удали макрос', 'забудь о макросе', + )).strip() + if not name: + _speak("Какой макрос удалить?") + return + if macros_store.delete(name): + _speak(f"Удалил макрос {name}.") + else: + _speak(f"Макрос {name} не найден.") + + +# ── scheduler ─────────────────────────────────────────────────────────────── + +def _parse_duration_phrase(voice: str) -> tuple[int, str] | None: + """From 'через 5 минут' / 'через 2 часа', return (count, unit_word).""" + low = (voice or '').lower() + m = re.search(r'(\d+)\s+(\S+)', low) + if not m: + return None + return (int(m.group(1)), m.group(2)) + + +def do_scheduler_add_reminder(action, voice): + body = _strip_trig_lower(voice, ( + 'напомни мне через', 'напомни через', + 'поставь напоминалку через', 'поставь напоминание через', + 'разбуди через', + )) + body = body.strip(' ,.:') + parsed = _parse_duration_phrase(body) + if not parsed: + _speak("Не понял через сколько.") + return + n, unit = parsed + if unit.startswith('минут'): + spec = f"in {n} minutes" + label = f"{n} минут" + elif unit.startswith('час'): + spec = f"in {n} hours" + label = f"{n} час" if n == 1 else (f"{n} часа" if n < 5 else f"{n} часов") + elif unit.startswith('секунд'): + spec = f"in {n} seconds" + label = f"{n} секунд" + else: + _speak("Не понял единицу. Минуты или часы.") + return + + # Body after the number+unit is the reminder text. + text = re.sub(r'^\d+\s+\S+\s*', '', body).strip() or "Напоминание." + try: + scheduler_store.add({ + 'name': 'Reminder', + 'schedule': spec, + 'action': {'type': 'speak', 'text': text}, + }) + except ValueError as exc: + _speak(f"Не получилось: {exc}.") + return + _speak(f"Напомню через {label}.") + + +def do_scheduler_add_recurring(action, voice): + body = _strip_trig_lower(voice, ( + 'напоминай каждые', 'напоминай каждый', + 'каждые', 'каждый час', + )) + body = body.strip(' ,.:') + parsed = _parse_duration_phrase(body) + if not parsed: + # try implicit 1 + m = re.match(r'^(\S+)', body) + if m and m.group(1) in ('час', 'минуту', 'минута'): + n = 1 + unit = m.group(1) + else: + _speak("Не понял через сколько повторять.") + return + else: + n, unit = parsed + + if unit.startswith('минут'): + spec = f"every {n} minutes" + sample = f"{n} минут" + elif unit.startswith('час'): + spec = f"every {n} hours" + sample = f"{n} час" if n == 1 else (f"{n} часа" if n < 5 else f"{n} часов") + else: + _speak("Не понял единицу.") + return + + text = re.sub(r'^\d+\s+\S+\s*', '', body).strip() or "Напоминание." + text = re.sub(r'^напоминай\s+', '', text).strip() or "Напоминание." + try: + scheduler_store.add({ + 'name': 'Recurring', + 'schedule': spec, + 'action': {'type': 'speak', 'text': text}, + }) + except ValueError as exc: + _speak(f"Не получилось: {exc}.") + return + _speak(f"Буду напоминать каждые {sample}.") + + +def do_scheduler_list(action, voice): + tasks = scheduler_store.list_all() + if not tasks: + _speak("Расписание пустое.") + return + line = f"Запланировано {len(tasks)}." + _speak(line) + + +def do_scheduler_clear(action, voice): + n = scheduler_store.clear() + if n == 0: + _speak("В расписании и так пусто.") + else: + _speak(f"Удалил {n} задач.") + + +def do_scheduler_cancel_by_text(action, voice): + query = _strip_trig_lower(voice, ( + 'отмени напоминание про', 'отмени напоминание о', + 'удали задачу про', 'удали задачу о', + 'отмени про', 'забудь напоминание про', + )).strip() + if not query: + _speak("Что именно отменить?") + return + n = scheduler_store.remove_by_text(query) + if n == 0: + _speak(f"Не нашёл напоминаний про {query}.") + elif n == 1: + _speak("Отменил напоминание.") + else: + _speak(f"Отменил {n} напоминаний про {query}.") + + +# ── interesting fact (LLM-dependent) ─────────────────────────────────────── + +def do_interesting_fact(action, voice): + """Asks the active LLM for a fun fact. Backend chosen by llm_backend.""" + client = llm_backend.current_client() + if client is None: + _speak("LLM не настроен.") + return + model = llm_backend.current_model() + + topic = '' + low = (voice or '').lower() + for trig in ('расскажи интересный факт про', 'расскажи интересный факт о', + 'интересный факт про', 'интересный факт о', + 'расскажи интересный факт', 'интересный факт', + 'расскажи факт', 'удиви меня'): + idx = low.find(trig) + if idx >= 0: + topic = (voice or '')[idx + len(trig):].strip(' ,.:').strip() + break + + if topic: + prompt = f"Расскажи один интересный факт про {topic}. На русском, 1-2 предложения. Без вступлений." + else: + prompt = "Расскажи один реально неочевидный научно-проверенный факт. На русском, 1-2 предложения. Без вступлений типа 'знали ли вы'." + + try: + resp = client.chat.completions.create( + model=model, + messages=[ + {'role': 'system', 'content': 'Ты любопытный собеседник. Цепляющие факты, без воды.'}, + {'role': 'user', 'content': prompt}, + ], + max_tokens=200, temperature=0.8, timeout=30, + ) + reply = resp.choices[0].message.content.strip() + if reply: + _speak(reply) + else: + _speak("Сегодня нет интересных фактов.") + except Exception as exc: + print(f"[ext] fact LLM failed: {exc}") + _speak("Не получилось получить факт.") + + +# ── LLM hot-swap ──────────────────────────────────────────────────────────── + +def do_llm_switch(action, voice): + """Hot-swap LLM backend. Action.target preferred, else parse from voice.""" + target = action.get('target') or '' + if not target: + # Try to extract from voice + low = (voice or '').lower() + if 'локал' in low or 'оллам' in low or 'ollama' in low: + target = 'ollama' + elif 'облак' in low or 'грок' in low or 'клауд' in low or 'groq' in low: + target = 'groq' + + parsed = llm_backend.parse_backend(target) + if not parsed: + _speak("Не понял какой движок.") + return + + try: + actual = llm_backend.swap_to(parsed) + except Exception as exc: + print(f"[llm] swap failed: {exc}") + human = "локальный" if parsed == 'ollama' else "облачный" + _speak(f"Не получилось переключиться на {human}.") + return + + if actual == 'groq': + _speak("Переключился на облачный мозг.") + else: + _speak("Переключился на локальный мозг.") + + +def do_llm_status(action, voice): + backend = llm_backend.current_backend() + if backend == 'groq': + _speak("Сейчас работаю на облаке.") + elif backend == 'ollama': + _speak("Сейчас работаю локально.") + else: + _speak("LLM не настроен.") + + +# ── Now Playing (Windows SMTC) ────────────────────────────────────────────── + +_NOW_PLAYING_PS = """ +[Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager,Windows.Media.Control,ContentType=WindowsRuntime] | Out-Null; +$mgr = [Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync(); +while ($mgr.Status -eq 0) { Start-Sleep -Milliseconds 50 }; +$session = $mgr.GetResults().GetCurrentSession(); +if (-not $session) { Write-Output 'NONE'; exit }; +$propsTask = $session.TryGetMediaPropertiesAsync(); +while ($propsTask.Status -eq 0) { Start-Sleep -Milliseconds 50 }; +$props = $propsTask.GetResults(); +Write-Output ("{0}|{1}" -f $props.Artist, $props.Title) +""".strip().replace('\n', '; ') + + +def do_now_playing(action, voice): + try: + res = subprocess.run( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', _NOW_PLAYING_PS], + capture_output=True, text=True, encoding='utf-8', timeout=8, + ) + except Exception as exc: + print(f"[ext] now_playing: {exc}") + _speak("Не получилось получить данные.") + return + + out = (res.stdout or '').strip() + if out == 'NONE' or not out: + _speak("Сейчас ничего не играет.") + return + if '|' not in out: + _speak("Не понял ответ системы.") + return + artist, _, title = out.partition('|') + artist, title = artist.strip(), title.strip() + if not title: + _speak("Трек без названия.") + return + if artist: + _speak(f"Сейчас играет: {artist} — {title}.") + else: + _speak(f"Сейчас играет: {title}.") + + +# ── World Clock ───────────────────────────────────────────────────────────── + +_TZ_MAP = { + 'москва': 'Europe/Moscow', + 'петербург': 'Europe/Moscow', 'спб': 'Europe/Moscow', + 'новосибирск': 'Asia/Novosibirsk', + 'екатеринбург': 'Asia/Yekaterinburg', + 'владивосток': 'Asia/Vladivostok', + 'омск': 'Asia/Omsk', + 'красноярск': 'Asia/Krasnoyarsk', + 'лондон': 'Europe/London', + 'нью-йорк': 'America/New_York', 'нью йорк': 'America/New_York', + 'токио': 'Asia/Tokyo', + 'пекин': 'Asia/Shanghai', + 'сидней': 'Australia/Sydney', + 'дубай': 'Asia/Dubai', + 'париж': 'Europe/Paris', + 'берлин': 'Europe/Berlin', + 'киев': 'Europe/Kyiv', + 'минск': 'Europe/Minsk', + 'алматы': 'Asia/Almaty', + 'ташкент': 'Asia/Tashkent', + 'сеул': 'Asia/Seoul', +} + + +def do_world_clock(action, voice): + import urllib.request + low = (voice or '').lower() + city = low + for trig in ('сколько времени в', 'который час в', 'сколько часов в', 'время в'): + idx = city.find(trig) + if idx >= 0: + city = city[idx + len(trig):].strip(' ,.:') + break + if not city: + _speak("В каком городе?") + return + + tz = None + for stem, zone in _TZ_MAP.items(): + if stem in city: + tz = zone + break + if not tz: + _speak("Не знаю такого города.") + return + + try: + with urllib.request.urlopen(f"https://worldtimeapi.org/api/timezone/{tz}", + timeout=8) as r: + import json as _json + data = _json.loads(r.read().decode()) + except Exception as exc: + print(f"[ext] world_clock: {exc}") + _speak("Не получилось узнать время.") + return + + dt = data.get('datetime', '') + m = re.search(r'T(\d{2}):(\d{2})', dt) + if not m: + _speak("Не понял ответ сервиса.") + return + _speak(f"В {city} сейчас {m.group(1)}:{m.group(2)}.") + + +# ── Daily quote ───────────────────────────────────────────────────────────── + +def do_daily_quote(action, voice): + import urllib.request + import json as _json + try: + with urllib.request.urlopen("https://zenquotes.io/api/random", timeout=8) as r: + data = _json.loads(r.read().decode()) + except Exception as exc: + print(f"[ext] daily_quote: {exc}") + _speak("Не получилось получить цитату.") + return + + if not data or not isinstance(data, list): + _speak("Пустой ответ.") + return + q = data[0] + quote_en = q.get('q', '') + author = q.get('a', 'Аноним') + if not quote_en: + _speak("Пустая цитата.") + return + + client = llm_backend.current_client() + text = quote_en + if client: + try: + resp = client.chat.completions.create( + model=llm_backend.current_model(), + messages=[ + {'role': 'system', 'content': 'Переведи английскую цитату на русский. Только перевод.'}, + {'role': 'user', 'content': quote_en}, + ], + max_tokens=200, temperature=0.2, timeout=20, + ) + translated = resp.choices[0].message.content.strip() + if translated: + text = translated + except Exception as exc: + print(f"[ext] daily_quote translate: {exc}") + + _speak(f"{text} — {author}.") + + +# ── codebase Q&A (proxies to dev_handlers) ────────────────────────────────── + +def do_codebase_set(action, voice): + dev_handlers.do_codebase_set(action, voice) + + +def do_codebase_where(action, voice): + dev_handlers.do_codebase_where(action, voice) + + +def do_codebase_ask(action, voice): + dev_handlers.do_codebase_ask(action, voice) + + +# ── GitHub PR (proxies to dev_handlers) ───────────────────────────────────── + +def do_github_set_repo(action, voice): + dev_handlers.do_github_set_repo(action, voice) + + +def do_github_list_prs(action, voice): + dev_handlers.do_github_list_prs(action, voice) + + +def do_github_summarize_pr(action, voice): + dev_handlers.do_github_summarize_pr(action, voice) + + +# ── Wave 1 proxies (to wave1_handlers) ────────────────────────────────────── + +def do_magic_8ball(a, v): wave1_handlers.do_magic_8ball(a, v) +def do_gh_list_issues(a, v): wave1_handlers.do_github_list_issues(a, v) +def do_gh_my_issues(a, v): wave1_handlers.do_github_my_issues(a, v) +def do_weather_tomorrow(a, v): wave1_handlers.do_weather_tomorrow(a, v) +def do_weather_week(a, v): wave1_handlers.do_weather_week(a, v) +def do_rss_add(a, v): wave1_handlers.do_rss_add(a, v) +def do_rss_read(a, v): wave1_handlers.do_rss_read(a, v) +def do_rss_list(a, v): wave1_handlers.do_rss_list(a, v) +def do_ics_create(a, v): wave1_handlers.do_ics_create(a, v) +def do_backup_export(a, v): wave1_handlers.do_backup_export(a, v) +def do_clip_save(a, v): wave1_handlers.do_clip_save(a, v) +def do_clip_list(a, v): wave1_handlers.do_clip_list(a, v) +def do_clip_restore(a, v): wave1_handlers.do_clip_restore(a, v) +def do_notif_missed(a, v): wave1_handlers.do_notif_missed(a, v) +def do_notif_clear(a, v): wave1_handlers.do_notif_clear(a, v) +def do_vault_save(a, v): wave1_handlers.do_vault_save(a, v) +def do_vault_get(a, v): wave1_handlers.do_vault_get(a, v) +def do_vault_list(a, v): wave1_handlers.do_vault_list(a, v) +def do_habit_checkin(a, v): wave1_handlers.do_habit_checkin(a, v) +def do_habit_streak(a, v): wave1_handlers.do_habit_streak(a, v) + + +# ── Personality proxies (to personality_handlers) ─────────────────────────── + +def do_personality_greet(a, v): personality_handlers.do_personality_greet(a, v) +def do_personality_thanks(a, v): personality_handlers.do_personality_thanks(a, v) +def do_personality_compliment(a, v): personality_handlers.do_personality_compliment(a, v) +def do_personality_how_are_you(a, v): personality_handlers.do_personality_how_are_you(a, v) +def do_personality_tony_quote(a, v): personality_handlers.do_personality_tony_quote(a, v) + + +# ── Time tracker proxies (to time_tracker_handlers) ───────────────────────── + +def do_tracker_start(a, v): time_tracker_handlers.do_tracker_start(a, v) +def do_tracker_stop(a, v): time_tracker_handlers.do_tracker_stop(a, v) +def do_tracker_today(a, v): time_tracker_handlers.do_tracker_today(a, v) +def do_tracker_week(a, v): time_tracker_handlers.do_tracker_week(a, v) +def do_tracker_reset(a, v): time_tracker_handlers.do_tracker_reset(a, v) + + +# ── Outlook proxies (to outlook_handlers) ─────────────────────────────────── + +def do_outlook_unread_count(a, v): outlook_handlers.do_outlook_unread_count(a, v) +def do_outlook_latest(a, v): outlook_handlers.do_outlook_latest(a, v) +def do_outlook_send_clipboard(a, v): outlook_handlers.do_outlook_send_clipboard(a, v) +def do_outlook_summarize_inbox(a, v): outlook_handlers.do_outlook_summarize_inbox(a, v) + + +# ── Wave 6-8 proxies (to wave68_handlers) ────────────────────────────────── + +def do_memory_count(a, v): wave68_handlers.do_memory_count(a, v) +def do_memory_list(a, v): wave68_handlers.do_memory_list(a, v) +def do_memory_wipe(a, v): wave68_handlers.do_memory_wipe(a, v) +def do_cooking(a, v): wave68_handlers.do_cooking(a, v) +def do_leftoff(a, v): wave68_handlers.do_leftoff(a, v) +def do_trivia(a, v): wave68_handlers.do_trivia(a, v) +def do_good_night(a, v): wave68_handlers.do_good_night(a, v) +def do_good_morning(a, v): wave68_handlers.do_good_morning(a, v) +def do_coffee_break(a, v): wave68_handlers.do_coffee_break(a, v) + + +# ── Expenses proxies (to expenses_handlers) ──────────────────────────────── + +def do_expenses_log(a, v): expenses_handlers.do_expenses_log(a, v) +def do_expenses_today(a, v): expenses_handlers.do_expenses_today(a, v) +def do_expenses_week(a, v): expenses_handlers.do_expenses_week(a, v) +def do_expenses_breakdown(a, v): expenses_handlers.do_expenses_breakdown(a, v) + + +# ── Wave 5-9 proxies (wol / banter / conversation / ddg_answer) ──────────── + +def do_wol(a, v): wave59_handlers.do_wol(a, v) +def do_banter_fire(a, v): wave59_handlers.do_banter_fire(a, v) +def do_banter_pause(a, v): wave59_handlers.do_banter_pause(a, v) +def do_banter_resume(a, v): wave59_handlers.do_banter_resume(a, v) +def do_conversation_summary(a, v): wave59_handlers.do_conversation_summary(a, v) +def do_conversation_repeat(a, v): wave59_handlers.do_conversation_repeat(a, v) +def do_ddg_answer(a, v): wave59_handlers.do_ddg_answer(a, v) diff --git a/gui.bat b/gui.bat new file mode 100644 index 0000000..6c8f127 --- /dev/null +++ b/gui.bat @@ -0,0 +1,5 @@ +@echo off +REM Launch the Flet GUI. Requires `pip install flet>=0.85`. +pushd "%~dp0" +python -m gui +popd diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..9c17412 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,19 @@ +"""Python Flet GUI — Russian-language desktop UI mirroring the Rust Tauri GUI. + +Entry point: `python -m gui` (see `__main__.py`). + +Layout: + gui/__init__.py # this docstring + gui/__main__.py # `python -m gui` entry + gui/app.py # routing, layout, theme + gui/pages/ # one file per page (home, commands, macros, ...) + gui/services.py # thin sync wrappers around store modules + +Design notes: +- Each page is a function `build(page: ft.Page) -> ft.Control` returning a + composable view. This keeps state per-page lazy and lets the user navigate + without prematurely loading everything. +- The GUI does not start the assistant; it only manipulates state files and + hot-launches `main.py` in a subprocess (mirroring how the Rust GUI starts + jarvis-app.exe via tray). +""" diff --git a/gui/__main__.py b/gui/__main__.py new file mode 100644 index 0000000..ff07ddf --- /dev/null +++ b/gui/__main__.py @@ -0,0 +1,5 @@ +"""`python -m gui` — launch the Flet GUI.""" +from gui.app import run + +if __name__ == "__main__": + run() diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000..afe2f4b --- /dev/null +++ b/gui/app.py @@ -0,0 +1,81 @@ +"""Flet GUI router and shell. + +Provides: +- Top navigation bar with the 6 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки) +- Page swap on click, no full reload +- Dark theme matching the Tauri GUI vibe + +Each page lives in `gui/pages/.py` and exposes `build(page) -> Control`. +""" + +import flet as ft + +from gui.pages import home, commands, macros, scheduler, memory, plugins, settings + + +PAGES = [ + ("Главная", home.build), + ("Команды", commands.build), + ("Макросы", macros.build), + ("Расписание", scheduler.build), + ("Память", memory.build), + ("Плагины", plugins.build), + ("Настройки", settings.build), +] + + +def _make_app(page: ft.Page): + page.title = "J.A.R.V.I.S. — Python GUI" + page.theme_mode = ft.ThemeMode.DARK + page.window.width = 940 + page.window.height = 720 + page.padding = 0 + page.bgcolor = "#0a1419" + + content_holder = ft.Container(expand=True, padding=20) + + def go(idx: int): + nav.selected_index = idx + title, builder = PAGES[idx] + try: + content_holder.content = builder(page) + except (RuntimeError, ValueError, OSError) as exc: + content_holder.content = ft.Text( + f"Не удалось открыть «{title}»:\n{exc}", + color="red", + ) + page.update() + + nav = ft.NavigationRail( + selected_index=0, + label_type=ft.NavigationRailLabelType.ALL, + min_width=80, + min_extended_width=180, + bgcolor="#0d1b21", + destinations=[ + ft.NavigationRailDestination(icon=ft.Icons.HOME, label="Главная"), + ft.NavigationRailDestination(icon=ft.Icons.LIST_ALT, label="Команды"), + ft.NavigationRailDestination(icon=ft.Icons.PLAY_CIRCLE_OUTLINE, label="Макросы"), + ft.NavigationRailDestination(icon=ft.Icons.SCHEDULE, label="Расписание"), + ft.NavigationRailDestination(icon=ft.Icons.MEMORY, label="Память"), + ft.NavigationRailDestination(icon=ft.Icons.EXTENSION, label="Плагины"), + ft.NavigationRailDestination(icon=ft.Icons.SETTINGS, label="Настройки"), + ], + on_change=lambda e: go(int(e.control.selected_index)), + ) + + layout = ft.Row( + controls=[ + nav, + ft.VerticalDivider(width=1, color="#1f3540"), + content_holder, + ], + expand=True, + ) + + page.add(layout) + go(0) + + +def run(): + ft.app(target=_make_app) diff --git a/gui/pages/__init__.py b/gui/pages/__init__.py new file mode 100644 index 0000000..2302ae1 --- /dev/null +++ b/gui/pages/__init__.py @@ -0,0 +1 @@ +"""Page modules: each exposes `build(page: flet.Page) -> flet.Control`.""" diff --git a/gui/pages/commands.py b/gui/pages/commands.py new file mode 100644 index 0000000..b8a622f --- /dev/null +++ b/gui/pages/commands.py @@ -0,0 +1,80 @@ +"""Commands page — read-only browser of `commands.yaml` + plugin packs.""" + +import os +import flet as ft +import yaml + + +_HERE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _load_commands() -> dict[str, dict]: + try: + with open(os.path.join(_HERE, "commands.yaml"), "rt", encoding="utf8") as f: + return yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError): + return {} + + +def build(page: ft.Page) -> ft.Control: # pylint: disable=unused-argument + cmds = _load_commands() + search = ft.TextField(label="Фильтр", value="", autofocus=False, expand=True) + list_view = ft.ListView(expand=True, spacing=6, padding=0) + + def render(query: str = ""): + q = (query or "").strip().lower() + list_view.controls.clear() + shown = 0 + for cmd_id, entry in sorted(cmds.items()): + phrases = entry.get("phrases") or [] + action = entry.get("action") or {} + joined = " ".join(phrases).lower() + if q and q not in cmd_id.lower() and q not in joined: + continue + shown += 1 + list_view.controls.append( + ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text(cmd_id, weight=ft.FontWeight.W_600, size=13), + ft.Container( + content=ft.Text(action.get("type", "?"), size=10), + bgcolor="#1e3a44", + padding=ft.padding.symmetric(horizontal=8, vertical=2), + border_radius=10, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text( + " · ".join(phrases[:6]) + ("…" if len(phrases) > 6 else ""), + size=11, + color="#8aa0a8", + ), + ], spacing=4), + bgcolor="#15252b", + border_radius=8, + padding=10, + ) + ) + # The header refers to the closure-captured `header` Text via the + # outer-scope binding declared after this function. + header.value = f"Команд показано: {shown} из {len(cmds)}" + page.update() + + search.on_change = lambda e: render(e.control.value) + + header = ft.Text(f"Всего команд: {len(cmds)}", size=12, color="#8aa0a8") + + render() + + return ft.Column( + controls=[ + ft.Row([ + ft.Text("Команды", size=24, weight=ft.FontWeight.W_700), + header, + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + search, + ft.Container(content=list_view, expand=True), + ], + spacing=10, + expand=True, + ) diff --git a/gui/pages/home.py b/gui/pages/home.py new file mode 100644 index 0000000..a59d59c --- /dev/null +++ b/gui/pages/home.py @@ -0,0 +1,119 @@ +"""Home page — quick launcher + active backends + counters.""" + +import flet as ft + +from gui import services as svc + + +def _stat_card(title: str, value: str, color: str = "#33d3a4") -> ft.Container: + return ft.Container( + content=ft.Column( + [ + ft.Text(title, size=11, color="#8aa0a8"), + ft.Text(value, size=22, weight=ft.FontWeight.W_600, color=color), + ], + spacing=2, + ), + bgcolor="#15252b", + border_radius=10, + padding=14, + expand=True, + ) + + +def build(page: ft.Page) -> ft.Control: + running = svc.assistant_is_running() + try: + llm_b, llm_m = svc.llm_active() + except (RuntimeError, OSError, ValueError) as exc: + llm_b, llm_m = "—", str(exc) + + counters = ft.Row( + controls=[ + _stat_card("Команды", str(_safe_count(svc, "commands"))), + _stat_card("Макросы", str(len(svc.macros_all()))), + _stat_card("Расписание", str(len(svc.scheduler_all()))), + _stat_card("Память (фактов)", str(len(svc.memory_all()))), + _stat_card("Плагины", str(len(svc.plugins_all()))), + ], + spacing=10, + ) + + status_chip = ft.Container( + content=ft.Text( + "АССИСТЕНТ РАБОТАЕТ" if running else "АССИСТЕНТ ОСТАНОВЛЕН", + size=12, + weight=ft.FontWeight.W_700, + color="#13191c", + ), + bgcolor="#33d3a4" if running else "#d8d8d8", + padding=ft.padding.symmetric(horizontal=12, vertical=6), + border_radius=20, + ) + + start_btn = ft.FilledButton( + text="Запустить J.A.R.V.I.S.", + icon=ft.Icons.PLAY_ARROW, + on_click=lambda e: _launch(page, e), + disabled=running, + ) + + backends_card = ft.Container( + content=ft.Column([ + ft.Text("Активный LLM", size=11, color="#8aa0a8"), + ft.Text(f"{llm_b} · {llm_m}", size=14, weight=ft.FontWeight.W_500), + ], spacing=2), + bgcolor="#15252b", + border_radius=10, + padding=14, + ) + + return ft.Column( + controls=[ + ft.Row([ + ft.Text("J.A.R.V.I.S.", size=28, weight=ft.FontWeight.W_700), + status_chip, + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text( + "Голосовой ассистент — Python-форк. Эта панель управляет состоянием, " + "запускает main.py и переключает движки.", + color="#8aa0a8", + size=12, + ), + ft.Divider(color="#1f3540"), + backends_card, + counters, + ft.Divider(color="#1f3540"), + start_btn, + ], + spacing=14, + expand=True, + scroll=ft.ScrollMode.AUTO, + ) + + +def _launch(page: ft.Page, _e) -> None: + ok = svc.assistant_run() + page.open( + ft.SnackBar( + content=ft.Text( + "Запускаю main.py" if ok else "Не удалось запустить main.py", + color="white", + ), + bgcolor="#33d3a4" if ok else "#d8584f", + ) + ) + + +def _safe_count(svc_module, kind: str) -> int: + """Counts come from disk; if a store fails we display 0 rather than crash.""" + try: + if kind == "commands": + # Don't import the full VA_CMD_LIST machinery — read yaml directly. + import os, yaml # pylint: disable=import-outside-toplevel + here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + with open(os.path.join(here, "commands.yaml"), "rt", encoding="utf8") as f: + return len(yaml.safe_load(f) or {}) + except (OSError, ValueError): + return 0 + return 0 diff --git a/gui/pages/macros.py b/gui/pages/macros.py new file mode 100644 index 0000000..f89267f --- /dev/null +++ b/gui/pages/macros.py @@ -0,0 +1,90 @@ +"""Macros page — list macros, show steps, delete. Recording is voice-only.""" + +import flet as ft + +from gui import services as svc + + +def build(page: ft.Page) -> ft.Control: + list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO) + + def render(): + list_view.controls.clear() + macros = svc.macros_all() + rec_on, rec_name = svc.macros_recording() + if rec_on: + list_view.controls.append(_recording_banner(rec_name)) + if not macros: + list_view.controls.append( + ft.Text( + "Макросов пока нет. Запиши голосом: «Запиши макрос работа», " + "потом скажи команды, потом «Сохрани макрос».", + color="#8aa0a8", + ) + ) + for m in sorted(macros, key=lambda x: x["name"]): + list_view.controls.append(_macro_card(m, on_delete=lambda n=m["name"]: do_delete(n))) + page.update() + + def do_delete(name: str) -> None: + if svc.macros_delete(name): + page.open(ft.SnackBar(content=ft.Text(f"Удалил «{name}»", color="white"), bgcolor="#33d3a4")) + render() + + render() + + return ft.Column( + [ + ft.Row([ + ft.Text("Макросы", size=24, weight=ft.FontWeight.W_700), + ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + list_view, + ], + expand=True, + spacing=10, + ) + + +def _recording_banner(name: str | None) -> ft.Control: + return ft.Container( + content=ft.Row([ + ft.Icon(ft.Icons.FIBER_MANUAL_RECORD, color="#ff5757"), + ft.Text( + f"Идёт запись макроса «{name or '—'}». " + "Произнесите команды, затем «Сохрани макрос».", + ), + ]), + bgcolor="#3a2020", + border_radius=8, + padding=10, + ) + + +def _macro_card(m: dict, on_delete) -> ft.Control: + steps = m.get("steps", []) + preview = steps[:5] + extra = len(steps) - len(preview) + return ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text(m["name"], weight=ft.FontWeight.W_600, size=14), + ft.Container( + content=ft.Text(f"{m['steps_count']} шагов", size=10), + bgcolor="#1e3a44", + padding=ft.padding.symmetric(horizontal=8, vertical=2), + border_radius=10, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Column([ + ft.Text(f"• {s}", size=11, color="#a8b8c0") for s in preview + ] + ([ft.Text(f"+ ещё {extra}…", size=11, color="#5a6a72", italic=True)] if extra > 0 else []), + spacing=2), + ft.Row([ + ft.OutlinedButton(text="Удалить", icon=ft.Icons.DELETE, on_click=lambda e: on_delete()), + ]), + ], spacing=8), + bgcolor="#15252b", + border_radius=8, + padding=12, + ) diff --git a/gui/pages/memory.py b/gui/pages/memory.py new file mode 100644 index 0000000..fb8f549 --- /dev/null +++ b/gui/pages/memory.py @@ -0,0 +1,74 @@ +"""Long-term memory page — list facts, add new ones, search, forget.""" + +import flet as ft + +from gui import services as svc + + +def build(page: ft.Page) -> ft.Control: + key_input = ft.TextField(label="Ключ", expand=True) + val_input = ft.TextField(label="Значение", expand=True) + search_input = ft.TextField(label="Поиск", expand=True) + + list_view = ft.Column(spacing=6, expand=True, scroll=ft.ScrollMode.AUTO) + + def render(query: str = ""): + list_view.controls.clear() + facts = svc.memory_search(query, limit=100) if query.strip() else svc.memory_all() + if not facts: + list_view.controls.append(ft.Text("Нет фактов. Скажи «Запомни что моя любимая еда — суши».", + color="#8aa0a8")) + for f in sorted(facts, key=lambda x: x.get("key", "")): + list_view.controls.append(_fact_row(f, on_forget=lambda k=f["key"]: do_forget(k))) + page.update() + + def do_add(_): + k = key_input.value.strip() + v = val_input.value.strip() + if not k or not v: + return + svc.memory_remember(k, v) + key_input.value = "" + val_input.value = "" + render(search_input.value or "") + + def do_forget(k: str): + svc.memory_forget(k) + render(search_input.value or "") + + search_input.on_change = lambda e: render(e.control.value) + render() + + return ft.Column( + [ + ft.Text("Долговременная память", size=24, weight=ft.FontWeight.W_700), + ft.Text( + "Ключ-значение, которые Jarvis передаёт LLM как контекст пользователя.", + color="#8aa0a8", size=12, + ), + ft.Row([key_input, val_input, ft.FilledButton(text="Добавить", on_click=do_add)]), + search_input, + list_view, + ], + spacing=10, + expand=True, + ) + + +def _fact_row(f: dict, on_forget) -> ft.Control: + return ft.Container( + content=ft.Row([ + ft.Column([ + ft.Text(f.get("key", ""), weight=ft.FontWeight.W_600, size=13), + ft.Text(f.get("value", ""), size=12, color="#a8b8c0"), + ], spacing=2, expand=True), + ft.IconButton( + icon=ft.Icons.DELETE_FOREVER, + tooltip="Забыть", + on_click=lambda e: on_forget(), + ), + ]), + bgcolor="#15252b", + border_radius=8, + padding=ft.padding.symmetric(horizontal=12, vertical=8), + ) diff --git a/gui/pages/plugins.py b/gui/pages/plugins.py new file mode 100644 index 0000000..233020c --- /dev/null +++ b/gui/pages/plugins.py @@ -0,0 +1,97 @@ +"""Plugins page — list user packs, toggle enabled, open folder.""" + +import flet as ft + +from gui import services as svc + + +def build(page: ft.Page) -> ft.Control: + list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO) + + def render(): + list_view.controls.clear() + packs = svc.plugins_all() + if not packs: + list_view.controls.append( + ft.Column([ + ft.Text( + "Плагинов пока нет. Создай папку с commands.yaml в " + "%APPDATA%\\com.priler.jarvis\\plugins\\, нажми «Обновить».", + color="#8aa0a8", + ), + ]) + ) + for p in packs: + list_view.controls.append(_pack_row(p, on_toggle=lambda n=p["name"], e=p["enabled"]: do_toggle(n, e))) + page.update() + + def do_toggle(name: str, was_enabled: bool): + ok, msg = svc.plugins_set_enabled(name, not was_enabled) + page.open(ft.SnackBar( + content=ft.Text( + "Перезапусти main.py, чтобы применить." if ok else f"Ошибка: {msg}", + color="white", + ), + bgcolor="#33d3a4" if ok else "#d8584f", + )) + render() + + def do_open(_): + path = svc.plugins_open_folder() + page.open(ft.SnackBar(content=ft.Text(f"Папка: {path}", color="white"), bgcolor="#33d3a4")) + + render() + + return ft.Column( + [ + ft.Row([ + ft.Text("Плагины", size=24, weight=ft.FontWeight.W_700), + ft.Row([ + ft.OutlinedButton(text="Открыть папку", icon=ft.Icons.FOLDER_OPEN, on_click=do_open), + ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()), + ]), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text( + "Папки в %APPDATA%\\com.priler.jarvis\\plugins\\, " + "каждая содержит свой commands.yaml. Изменения подхватываются после рестарта main.py.", + color="#8aa0a8", size=12, + ), + list_view, + ], + spacing=10, + expand=True, + ) + + +def _pack_row(p: dict, on_toggle) -> ft.Control: + err = p.get("error") + return ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text(p["name"], weight=ft.FontWeight.W_600, size=14), + ft.Container( + content=ft.Text( + "ошибка" if err else f"{p['command_count']} команд", + size=10, color="white", + ), + bgcolor="#7a2c2c" if err else ("#1e6b5a" if p["enabled"] else "#404040"), + padding=ft.padding.symmetric(horizontal=8, vertical=2), + border_radius=10, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text(p["path"], size=10, color="#5a6a72", font_family="Cascadia Mono"), + ft.Text(err, size=11, color="#ff8888") if err else ft.Container(height=0), + ft.Row([ + ft.Switch( + value=p["enabled"], + on_change=lambda e: on_toggle(), + disabled=bool(err), + ), + ft.Text("Включён" if p["enabled"] else "Выключен", + size=11, color="#33d3a4" if p["enabled"] else "#8aa0a8"), + ]), + ], spacing=6), + bgcolor="#15252b", + border_radius=8, + padding=12, + ) diff --git a/gui/pages/scheduler.py b/gui/pages/scheduler.py new file mode 100644 index 0000000..d46f05f --- /dev/null +++ b/gui/pages/scheduler.py @@ -0,0 +1,80 @@ +"""Scheduler page — list tasks + remove.""" + +import datetime +import flet as ft + +from gui import services as svc + + +def _fmt_ts(ts) -> str: + if not ts: + return "—" + try: + return datetime.datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OSError): + return "—" + + +def build(page: ft.Page) -> ft.Control: + list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO) + + def render(): + list_view.controls.clear() + tasks = svc.scheduler_all() + if not tasks: + list_view.controls.append( + ft.Text("Запланированных задач нет. Создавай голосом: «Каждые 30 минут напомни попить воды».", + color="#8aa0a8")) + for t in sorted(tasks, key=lambda x: x.get("created_at", 0)): + list_view.controls.append(_task_card(t, on_delete=lambda tid=t["id"]: do_remove(tid))) + page.update() + + def do_remove(task_id: str): + if svc.scheduler_remove(task_id): + page.open(ft.SnackBar(content=ft.Text("Задача удалена", color="white"), bgcolor="#33d3a4")) + render() + + render() + + return ft.Column( + [ + ft.Row([ + ft.Text("Расписание", size=24, weight=ft.FontWeight.W_700), + ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + list_view, + ], + spacing=10, + expand=True, + ) + + +def _task_card(t: dict, on_delete) -> ft.Control: + name = t.get("name", "?") + sched_h = t.get("schedule_human") or t.get("schedule_kind") or "?" + action = t.get("action") or {} + action_text = action.get("text") or action.get("speak") or action.get("phrase") or "(нет текста)" + return ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text(name, weight=ft.FontWeight.W_600, size=14), + ft.Container( + content=ft.Text(sched_h, size=10), + bgcolor="#1e3a44", + padding=ft.padding.symmetric(horizontal=8, vertical=2), + border_radius=10, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text(f"Действие: {action_text}", size=11, color="#a8b8c0"), + ft.Row([ + ft.Text(f"создана: {_fmt_ts(t.get('created_at'))}", size=10, color="#5a6a72"), + ft.Text(f"последний запуск: {_fmt_ts(t.get('last_fired'))}", size=10, color="#5a6a72"), + ]), + ft.Row([ + ft.OutlinedButton(text="Удалить", icon=ft.Icons.DELETE, on_click=lambda e: on_delete()), + ]), + ], spacing=6), + bgcolor="#15252b", + border_radius=8, + padding=12, + ) diff --git a/gui/pages/settings.py b/gui/pages/settings.py new file mode 100644 index 0000000..b3db0bd --- /dev/null +++ b/gui/pages/settings.py @@ -0,0 +1,77 @@ +"""Settings page — active profile + LLM backend swap.""" + +import flet as ft + +from gui import services as svc + + +def build(page: ft.Page) -> ft.Control: + profiles = svc.profile_list() + active_profile = svc.profile_active() + try: + llm_b, llm_m = svc.llm_active() + except (RuntimeError, OSError, ValueError) as exc: + llm_b, llm_m = "ошибка", str(exc) + + profile_dd = ft.Dropdown( + label="Активный профиль", + options=[ft.dropdown.Option(p) for p in profiles], + value=active_profile, + expand=True, + ) + + def apply_profile(_): + target = profile_dd.value + if not target: + return + svc.profile_set(target) + page.open(ft.SnackBar(content=ft.Text(f"Профиль: {target}", color="white"), bgcolor="#33d3a4")) + + llm_dd = ft.Dropdown( + label="LLM бэкенд", + options=[ft.dropdown.Option("groq"), ft.dropdown.Option("ollama")], + value=llm_b if llm_b in ("groq", "ollama") else "groq", + expand=True, + ) + + def apply_llm(_): + target = llm_dd.value + if not target: + return + try: + new = svc.llm_swap(target) + page.open(ft.SnackBar(content=ft.Text(f"LLM → {new}", color="white"), bgcolor="#33d3a4")) + except (ValueError, RuntimeError, OSError) as exc: + page.open(ft.SnackBar(content=ft.Text(f"Ошибка: {exc}", color="white"), bgcolor="#d8584f")) + + return ft.Column( + [ + ft.Text("Настройки", size=24, weight=ft.FontWeight.W_700), + ft.Container( + content=ft.Column([ + ft.Text("Профиль", size=14, weight=ft.FontWeight.W_500), + ft.Text( + "Профили ограничивают какие команды активны (например, «sleep» отрубает шумные).", + color="#8aa0a8", size=11, + ), + ft.Row([profile_dd, ft.FilledButton(text="Применить", on_click=apply_profile)]), + ], spacing=8), + bgcolor="#15252b", + border_radius=8, + padding=14, + ), + ft.Container( + content=ft.Column([ + ft.Text("LLM", size=14, weight=ft.FontWeight.W_500), + ft.Text(f"Сейчас: {llm_b} · {llm_m}", color="#8aa0a8", size=11), + ft.Row([llm_dd, ft.FilledButton(text="Переключить", on_click=apply_llm)]), + ], spacing=8), + bgcolor="#15252b", + border_radius=8, + padding=14, + ), + ], + spacing=14, + expand=True, + scroll=ft.ScrollMode.AUTO, + ) diff --git a/gui/services.py b/gui/services.py new file mode 100644 index 0000000..7d76728 --- /dev/null +++ b/gui/services.py @@ -0,0 +1,159 @@ +"""Thin wrappers around the store modules + helpers the GUI pages share. + +Putting these here means each page imports `from gui import services as svc` +rather than importing internal store modules directly. If we later swap an +implementation (e.g. switching memory backend), only this file changes. +""" + +import os +import sys +import subprocess + +# Make sure project root is on sys.path when launched as `python -m gui` +# from inside C:\Jarvis\python\. +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +import memory_store +import profiles_store +import macros_store +import scheduler_store +import plugins_store +import llm_backend + + +# ---------- memory ---------- + +def memory_all() -> list[dict]: + return memory_store.all_facts() + + +def memory_remember(key: str, value: str) -> None: + memory_store.remember(key, value) + + +def memory_forget(key: str) -> bool: + return memory_store.forget(key) + + +def memory_search(query: str, limit: int = 5) -> list[dict]: + return memory_store.search(query, limit) + + +# ---------- profiles ---------- + +def profile_list() -> list[str]: + return profiles_store.list_names() + + +def profile_active() -> str: + return profiles_store.active_name() + + +def profile_set(name: str) -> dict: + return profiles_store.set_active(name) + + +# ---------- macros ---------- + +def macros_all() -> list[dict]: + out = [] + for name in macros_store.list_names(): + m = macros_store.get(name) or {} + out.append({ + "name": name, + "steps_count": len(m.get("steps", [])), + "steps": m.get("steps", []), + "created_at": m.get("created_at", 0), + "last_run": m.get("last_run"), + }) + return out + + +def macros_delete(name: str) -> bool: + return macros_store.delete(name) + + +def macros_recording() -> tuple[bool, str | None]: + return macros_store.is_recording(), macros_store.recording_name() + + +# ---------- scheduler ---------- + +def scheduler_all() -> list[dict]: + return scheduler_store.list_all() + + +def scheduler_remove(task_id: str) -> bool: + return scheduler_store.remove(task_id) + + +# ---------- plugins ---------- + +def plugins_all() -> list[dict]: + return plugins_store.list_packs() + + +def plugins_open_folder() -> str: + p = plugins_store.plugins_dir() + if os.name == "nt": + try: + subprocess.Popen(["explorer.exe", p]) + except OSError: + pass + return p + + +def plugins_set_enabled(name: str, enabled: bool) -> tuple[bool, str]: + return plugins_store.set_enabled(name, enabled) + + +# ---------- LLM ---------- + +def llm_active() -> tuple[str, str]: + return llm_backend.current_backend(), llm_backend.current_model() + + +def llm_swap(target: str) -> str: + return llm_backend.swap_to(target) + + +# ---------- assistant launcher ---------- + +def assistant_run() -> bool: + """Spawn main.py in a detached subprocess. Returns True on launch.""" + try: + main_py = os.path.join(_PROJECT_ROOT, "main.py") + # CREATE_NEW_PROCESS_GROUP so it doesn't get killed with the GUI + creationflags = 0 + if os.name == "nt": + creationflags = 0x00000200 # CREATE_NEW_PROCESS_GROUP + subprocess.Popen( + [sys.executable, main_py], + cwd=_PROJECT_ROOT, + creationflags=creationflags, + ) + return True + except OSError as e: + print(f"[gui] failed to launch assistant: {e}") + return False + + +def assistant_is_running() -> bool: + """Crude check: look for a python process running main.py from our dir. + + Implementation note: we deliberately avoid pulling psutil — keeps the GUI + install footprint small. The Rust fork uses sysinfo; here we just scan + `tasklist` on Windows. + """ + if os.name != "nt": + return False + try: + out = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq python.exe", "/V", "/FO", "CSV"], + capture_output=True, text=True, timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return False + return "main.py" in out.stdout diff --git a/llm_backend.py b/llm_backend.py new file mode 100644 index 0000000..51bf468 --- /dev/null +++ b/llm_backend.py @@ -0,0 +1,152 @@ +"""LLM backend singleton — Python port of `crates/jarvis-core/src/llm/mod.rs`. + +Supports two backends: + - Groq (cloud) — uses config.GROQ_TOKEN + - Ollama (local) — connects to OLLAMA_BASE_URL (default localhost:11434/v1) + +Active choice persists in `/llm_backend.txt`. Loaded on first call. + +Public API: + current_client() -> OpenAI client for the active backend, or None + current_backend() -> 'groq' | 'ollama' | 'none' + current_model() -> model name string + swap_to(name) -> 'groq' | 'ollama', persists choice + parse_backend(name) -> normalized 'groq' | 'ollama' | None +""" + +import os +import threading + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_CHOICE_FILE = os.path.join(_HERE, 'llm_backend.txt') + +_lock = threading.Lock() +_client = None +_backend = 'none' +_model = '' +_initialized = False + + +def _read_persisted() -> str: + try: + with open(_CHOICE_FILE, encoding='utf-8') as f: + return f.read().strip().lower() + except OSError: + return '' + + +def _persist(name: str) -> None: + try: + with open(_CHOICE_FILE, 'w', encoding='utf-8') as f: + f.write(name) + except OSError as exc: + print(f"[llm] persist: {exc}") + + +def parse_backend(name: str) -> str | None: + """Normalize a user-facing name to 'groq' / 'ollama' / None.""" + n = (name or '').strip().lower() + if n in ('groq', 'cloud', 'облако', 'клауд'): + return 'groq' + if n in ('ollama', 'local', 'локал', 'локальн', 'локальный'): + return 'ollama' + return None + + +def _build_groq(): + """Try to construct a Groq client. Returns (client, model) or raises.""" + try: + import config as cfg + except ImportError: + raise RuntimeError("config.py не найден") + if not getattr(cfg, 'GROQ_TOKEN', None): + raise RuntimeError("GROQ_TOKEN отсутствует в config") + from openai import OpenAI + base = getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1') + model = getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile') + return OpenAI(api_key=cfg.GROQ_TOKEN, base_url=base), model + + +def _build_ollama(): + """Construct an Ollama-talking OpenAI-compatible client.""" + from openai import OpenAI + base = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434/v1') + model = os.environ.get('OLLAMA_MODEL', 'qwen2.5:3b') + # Ollama doesn't need a real key, but openai lib insists on a non-empty string. + return OpenAI(api_key='ollama', base_url=base), model + + +def _build_for(backend: str): + if backend == 'groq': + return _build_groq() + if backend == 'ollama': + return _build_ollama() + raise ValueError(f"unknown backend: {backend}") + + +def _auto_detect_backend() -> str: + """Pick a default backend if none persisted: Groq if token present, else Ollama.""" + try: + import config as cfg + if getattr(cfg, 'GROQ_TOKEN', None): + return 'groq' + except ImportError: + pass + return 'ollama' + + +def _ensure_init(): + """Build the client based on persisted choice, env, or auto-detect.""" + global _client, _backend, _model, _initialized + if _initialized: + return + persisted = _read_persisted() + env_choice = os.environ.get('JARVIS_LLM', '').strip().lower() + backend = persisted or env_choice or _auto_detect_backend() + try: + client, model = _build_for(backend) + _client = client + _backend = backend + _model = model + except Exception as exc: + print(f"[llm] init {backend} failed: {exc}") + _client = None + _backend = 'none' + _model = '' + _initialized = True + + +def current_client(): + """Get the active client (or None if uninitialised). Cheap after first call.""" + with _lock: + _ensure_init() + return _client + + +def current_backend() -> str: + with _lock: + _ensure_init() + return _backend + + +def current_model() -> str: + with _lock: + _ensure_init() + return _model + + +def swap_to(name: str) -> str: + """Hot-swap to a different backend. Persists to disk. Returns new name.""" + target = parse_backend(name) + if not target: + raise ValueError(f"unknown backend: {name}") + with _lock: + global _client, _backend, _model, _initialized + client, model = _build_for(target) # may raise + _client = client + _backend = target + _model = model + _initialized = True + _persist(target) + print(f"[llm] swapped → {target} ({model})") + return target diff --git a/macros_store.py b/macros_store.py new file mode 100644 index 0000000..05566a2 --- /dev/null +++ b/macros_store.py @@ -0,0 +1,179 @@ +"""Voice-macro recorder — port of `crates/jarvis-core/src/macros.rs`. + +Records successful command phrases, replays them through the dispatch later. +JSON file at `/macros.json`. + +Public API: + is_recording() / recording_name() + start(name) / save() / cancel() + list_names() / get(name) / delete(name) + record_step(phrase) # called by main.py on each successful command + replay(name, dispatch_fn) # dispatch_fn fires one phrase +""" + +import json +import os +import time +import threading + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'macros.json') + +_recording: dict | None = None # {name, steps: list[str]} +_store: dict[str, dict] = {} +_loaded = False +_recording_lock = threading.Lock() + + +def _load(): + global _store, _loaded + if _loaded: + return + if os.path.isfile(_PATH): + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + _store = data.get('macros', {}) if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + print(f"[macros] corrupt: {exc}") + _store = {} + _loaded = True + + +def _save(): + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump({'macros': _store}, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as exc: + print(f"[macros] save: {exc}") + + +def _normalize(s: str) -> str: + return (s or '').strip().lower() + + +_CONTROL_TOKENS = ( + 'запиши макрос', 'сохрани макрос', 'отмени макрос', + 'прекрати макрос', 'отмени запись', 'запусти макрос', + 'воспроизведи макрос', 'удали макрос', +) + + +def _is_control(phrase: str) -> bool: + low = (phrase or '').lower() + return any(tok in low for tok in _CONTROL_TOKENS) + + +def is_recording() -> bool: + with _recording_lock: + return _recording is not None + + +def recording_name() -> str | None: + with _recording_lock: + return _recording['name'] if _recording else None + + +def start(name: str) -> None: + nname = _normalize(name) + if not nname: + raise ValueError("empty macro name") + with _recording_lock: + global _recording + _recording = {'name': nname, 'steps': []} + + +def cancel() -> bool: + with _recording_lock: + global _recording + if _recording is None: + return False + _recording = None + return True + + +def save() -> int: + """Stop recording, persist, return step count. Raises if nothing recorded.""" + with _recording_lock: + global _recording + if _recording is None: + raise RuntimeError("не было активной записи") + if not _recording['steps']: + _recording = None + raise RuntimeError("макрос пустой") + + _load() + name = _recording['name'] + steps = list(_recording['steps']) + _recording = None + + _store[name] = { + 'name': name, + 'steps': steps, + 'created_at': int(time.time()), + 'last_run': None, + } + _save() + return len(steps) + + +def record_step(phrase: str) -> None: + """Called by main.py after every successful command execution.""" + if not phrase: + return + if _is_control(phrase): + return + with _recording_lock: + if _recording is None: + return + _recording['steps'].append(phrase.strip()) + + +def list_names() -> list[str]: + _load() + return sorted(_store.keys()) + + +def get(name: str) -> dict | None: + _load() + return _store.get(_normalize(name)) + + +def delete(name: str) -> bool: + _load() + nname = _normalize(name) + if nname in _store: + del _store[nname] + _save() + return True + return False + + +def replay(name: str, dispatch_fn) -> int: + """Replay macro by name. dispatch_fn(phrase) fires a synthetic command. + Returns step count. Raises if macro missing. + Runs in a background thread with 800ms inter-step delay.""" + _load() + nname = _normalize(name) + m = _store.get(nname) + if not m: + raise KeyError(f"Макрос '{name}' не найден.") + + steps = list(m['steps']) + + def runner(): + for i, step in enumerate(steps): + try: + dispatch_fn(step) + except Exception as exc: + print(f"[macros] replay step {i}: {exc}") + if i + 1 < len(steps): + time.sleep(0.8) + m['last_run'] = int(time.time()) + _save() + + threading.Thread(target=runner, name=f"macro-replay-{nname}", + daemon=True).start() + return len(steps) diff --git a/main.py b/main.py index dbb75a7..ec824fa 100644 --- a/main.py +++ b/main.py @@ -10,13 +10,68 @@ import time import webbrowser from ctypes import POINTER, cast +# 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 import openai from openai import OpenAI -import simpleaudio as sa import vosk import webrtcvad import yaml + +# 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) + +import extensions # new-command handlers ported from rust fork +import plugins_store # user plugin packs under APP_CONFIG_DIR/plugins/ from comtypes import CLSCTX_ALL from fuzzywuzzy import fuzz from pvrecorder import PvRecorder @@ -36,6 +91,18 @@ VA_CMD_LIST = yaml.safe_load( open('commands.yaml', 'rt', encoding='utf8'), ) +# 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). " @@ -45,6 +112,15 @@ system_message = {"role": "system", "content": ( )} message_log = [system_message] +# 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") @@ -139,14 +215,24 @@ def play(phrase, wait_done=True): if wait_done: recorder.stop() - wave_obj = sa.WaveObject.from_wave_file(filename) - play_obj = wave_obj.play() + # 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: - play_obj.wait_done() - # time.sleep((len(wave_obj.audio_data) / wave_obj.sample_rate) + 0.5) - # print("END") - # time.sleep(0.5) recorder.start() @@ -168,10 +254,12 @@ def va_respond(voice: str): if len(cmd['cmd'].strip()) <= 0: return False elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys(): - # play("not_found") - # tts.va_speak("Что?") - if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75: - + auto_ok = ( + config.LLM_AUTO_FALLBACK + and config.GROQ_TOKEN + and len(voice.strip()) >= config.LLM_AUTO_FALLBACK_MIN_CHARS + ) + if auto_ok: message_log.append({"role": "user", "content": voice}) response = gpt_answer() message_log.append({"role": "assistant", "content": response}) @@ -224,6 +312,1202 @@ def _shutdown(): sys.exit(0) +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}") + + +# Wire extensions module to our voice + clipboard helpers +extensions.set_speak(_speak) +extensions.set_clipboard_fn(_set_clipboard) +extensions.init_background_services() # boots scheduler thread + + +_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)}.") + + +_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]}") + + +_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)}.") + + +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("Открываю настройки звука, сэр.") + + +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 + + +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'\s*<!\[CDATA\[(.+?)\]\]>\s*', body) + if not titles: + titles = _re_reminders.findall(r'\s*(.+?)\s*', 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)}.") + + +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': @@ -269,14 +1553,297 @@ def run_action(action): elif t == 'multi': for step in action['steps']: run_action(step) + 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 '') + 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 '') + 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 '') + 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 '') + # ── 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 '') + 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 '') + 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 '') + elif t == 'llm_switch': + extensions.do_llm_switch(action, _current_voice or '') + elif t == 'llm_status': + extensions.do_llm_status(action, _current_voice or '') + 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 '') + 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 '') + + +_current_voice: str = '' def execute_cmd(cmd: str, voice: str): + global _current_voice spec = VA_CMD_LIST.get(cmd) if not spec: return action = spec['action'] - run_action(action) + _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' @@ -284,6 +1851,44 @@ def execute_cmd(cmd: str, voice: str): 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) + + +# 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) diff --git a/memory_store.py b/memory_store.py new file mode 100644 index 0000000..78dc0ee --- /dev/null +++ b/memory_store.py @@ -0,0 +1,136 @@ +"""Long-term memory store — port of `crates/jarvis-core/src/long_term_memory.rs`. + +Stores user facts in JSON at `/long_term_memory.json`. +Atomic write-through (write-then-rename). + +Public API: + remember(key, value) -> None + recall(key) -> str | None + forget(key) -> bool + search(query, limit=5) -> list[dict] + all_facts() -> list[dict] + build_llm_context(prompt, limit=5) -> str # used by chat handler +""" + +import json +import os +import time + +# JSON file lives in the same folder as main.py so the user can move the +# install and the data follows. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'long_term_memory.json') + +_store: dict[str, dict] = {} +_loaded = False + + +def _load(): + global _store, _loaded + if _loaded: + return + if os.path.isfile(_PATH): + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + _store = data.get('entries', {}) if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + print(f"[memory] corrupt store ({exc}) — starting empty") + _store = {} + _loaded = True + + +def _save(): + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump({'entries': _store}, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as exc: + print(f"[memory] save failed: {exc}") + + +def _normalize_key(k: str) -> str: + return (k or '').strip().lower() + + +def remember(key: str, value: str) -> None: + _load() + nk = _normalize_key(key) + if not nk: + return + now = int(time.time()) + if nk in _store: + _store[nk]['value'] = value + _store[nk]['last_used_at'] = now + else: + _store[nk] = { + 'key': nk, + 'value': value, + 'created_at': now, + 'last_used_at': now, + 'use_count': 0, + } + _save() + + +def recall(key: str) -> str | None: + _load() + nk = _normalize_key(key) + entry = _store.get(nk) + if not entry: + return None + entry['last_used_at'] = int(time.time()) + entry['use_count'] = entry.get('use_count', 0) + 1 + _save() + return entry['value'] + + +def forget(key: str) -> bool: + _load() + nk = _normalize_key(key) + if nk in _store: + del _store[nk] + _save() + return True + return False + + +def clear_all() -> int: + """Wipe every fact. Returns count of removed entries. Atomic via _save.""" + _load() + n = len(_store) + if n == 0: + return 0 + _store.clear() + _save() + return n + + +def search(query: str, limit: int = 5) -> list[dict]: + _load() + nq = _normalize_key(query) + if not nq: + return [] + hits = [ + e for e in _store.values() + if nq in e['key'] or nq in e['value'].lower() + ] + hits.sort(key=lambda e: e.get('last_used_at', 0), reverse=True) + return hits[: max(1, limit)] + + +def all_facts() -> list[dict]: + _load() + return list(_store.values()) + + +def build_llm_context(prompt: str, limit: int = 5) -> str: + """Format relevant memory facts as a system-prompt addendum.""" + hits = search(prompt, limit) + if not hits: + return '' + lines = ["Известные факты о пользователе (используй если уместно):"] + for h in hits: + lines.append(f"- {h['key']} = {h['value']}") + return '\n'.join(lines) + '\n' diff --git a/outlook_handlers.py b/outlook_handlers.py new file mode 100644 index 0000000..093046d --- /dev/null +++ b/outlook_handlers.py @@ -0,0 +1,308 @@ +"""Outlook voice-command handlers — Python parity for the Rust `outlook` pack. + +Talks to Outlook via the `win32com.client` COM bridge (pywin32). When pywin32 +isn't installed or Outlook isn't running we degrade gracefully: every handler +reports the failure through the speak callback instead of crashing. + +Exposes four handlers, mirroring the Rust pack: + do_outlook_unread_count(action, voice) + do_outlook_latest(action, voice) + do_outlook_send_clipboard(action, voice) + do_outlook_summarize_inbox(action, voice) +""" + +import re +import subprocess +from typing import Optional + +# Lazy import — pywin32 is Windows-only and may not be installed on dev/CI. +try: + import win32com.client as _w32com # type: ignore + _W32_OK = True +except Exception: # ImportError, OSError, etc. + _w32com = None + _W32_OK = False + + +_speak_fn = print + + +def set_speak(fn): + """Register the voice output callback (called by extensions.set_speak).""" + global _speak_fn + _speak_fn = fn + + +def _speak(text: str) -> None: + try: + _speak_fn(text) + except Exception as exc: + print(f"[outlook] speak: {exc}") + + +# ── Outlook session helpers ──────────────────────────────────────────────── + +def _outlook_session(): + """Return (app, namespace) or (None, None) on any failure.""" + if not _W32_OK: + return None, None + try: + app = _w32com.Dispatch("Outlook.Application") + ns = app.GetNamespace("MAPI") + return app, ns + except Exception as exc: + print(f"[outlook] session: {exc}") + return None, None + + +def _unavailable_msg() -> str: + return "Outlook недоступен — запусти Outlook сначала." + + +def _strip_html(s: str) -> str: + if not s: + return "" + s = re.sub(r"<[^>]+>", " ", s) + s = (s.replace(" ", " ").replace("&", "&") + .replace("<", "<").replace(">", ">") + .replace(""", '"').replace("'", "'")) + return re.sub(r"\s+", " ", s).strip() + + +def _get_clipboard() -> str: + """Fetch clipboard via PowerShell. Returns "" on failure.""" + try: + res = subprocess.run( + ["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"], + capture_output=True, text=True, encoding="utf-8", timeout=10, + ) + return (res.stdout or "").strip() + except Exception as exc: + print(f"[outlook] clipboard: {exc}") + return "" + + +# ── handlers ─────────────────────────────────────────────────────────────── + +def do_outlook_unread_count(action, voice): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) # olFolderInbox + # Prefer the cheap property; fall back to scanning items. + try: + count = int(inbox.UnReadItemCount) + except Exception: + count = sum(1 for it in inbox.Items if getattr(it, "UnRead", False)) + except Exception as exc: + print(f"[outlook] unread_count: {exc}") + _speak(_unavailable_msg()) + return + + if count == 0: + _speak("Непрочитанных писем нет.") + elif count == 1: + _speak("Одно непрочитанное письмо.") + else: + _speak(f"Непрочитанных писем: {count}.") + + +def do_outlook_latest(action, voice): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) + items = inbox.Items + items.Sort("[ReceivedTime]", True) + latest = None + for it in items: + try: + if it.Class == 43: # olMail + latest = it + break + except Exception: + continue + if latest is None: + _speak("Входящие пусты.") + return + from_name = ( + getattr(latest, "SenderName", "") + or getattr(latest, "SenderEmailAddress", "") + or "неизвестного отправителя" + ) + subject = getattr(latest, "Subject", "") or "без темы" + body = getattr(latest, "Body", "") or "" + if not body: + body = _strip_html(getattr(latest, "HTMLBody", "") or "") + body = re.sub(r"\s+", " ", body).strip() + if len(body) > 200: + body = body[:200] + "..." + except Exception as exc: + print(f"[outlook] latest: {exc}") + _speak(_unavailable_msg()) + return + + _speak(f"Последнее письмо от {from_name}. Тема: {subject}. {body}") + + +_SEND_TRIGGERS = ( + "отправь письмо ", "отправь почту ", "напиши письмо ", "отправь это ", + "email this to ", "send email to ", "send this email to ", "mail this to ", +) + + +def _extract_recipient(voice: str) -> str: + """Pull the recipient name off the voice command by stripping the trigger.""" + if not voice: + return "" + low = voice.lower() + for trig in _SEND_TRIGGERS: + if trig in low: + idx = low.find(trig) + return voice[idx + len(trig):].strip() + return voice.strip() + + +def _resolve_recipient(ns, query: str) -> Optional[dict]: + if not query or ns is None: + return None + try: + r = ns.CreateRecipient(query) + r.Resolve() + if not r.Resolved: + return None + addr = None + try: + addr = r.AddressEntry.GetExchangeUser().PrimarySmtpAddress + except Exception: + pass + if not addr: + try: + addr = r.AddressEntry.Address + except Exception: + pass + if not addr: + addr = r.Name + return {"name": r.Name, "address": addr} + except Exception as exc: + print(f"[outlook] resolve: {exc}") + return None + + +def do_outlook_send_clipboard(action, voice): + recipient = _extract_recipient(voice or "") + if not recipient: + _speak("Кому отправить? Назови имя.") + return + + app, ns = _outlook_session() + if ns is None or app is None: + _speak(_unavailable_msg()) + return + + resolved = _resolve_recipient(ns, recipient) + if not resolved: + _speak(f"Не нашёл контакт «{recipient}».") + return + + body = _get_clipboard() + first_line = (body.split("\n", 1)[0] if body else "").strip() + if len(first_line) > 60: + subject = first_line[:60].strip() + else: + subject = first_line + if not subject: + subject = "From J.A.R.V.I.S." + + try: + mail = app.CreateItem(0) # olMailItem + mail.To = resolved["address"] + mail.Subject = subject + mail.Body = body + mail.Send() + except Exception as exc: + print(f"[outlook] send: {exc}") + _speak("Не получилось отправить — Outlook отказал.") + return + + _speak(f"Отправил {resolved['name']}. Тема: {subject}.") + + +def _llm_summarize(emails) -> Optional[str]: + """Call the configured LLM to summarise the email list. Returns None on + any failure, callers should provide a fallback.""" + try: + import llm_backend + except Exception: + return None + if not emails: + return None + listing = "\n".join( + f"{i+1}. From: {m['from']} | Subject: {m['subject']}" + for i, m in enumerate(emails) + ) + sys_prompt = ( + "Ты помощник по почте. Кратко опиши, что лежит в инбоксе, ОДНИМ абзацем " + "(2-4 предложения). Сгруппируй по темам/отправителям если возможно. " + "Без преамбулы, без перечисления списком." + ) + user_prompt = "Непрочитанные письма:\n" + listing + try: + reply = llm_backend.complete( + messages=[ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": user_prompt}, + ], + max_tokens=250, + temperature=0.4, + ) + if reply and isinstance(reply, str) and reply.strip(): + return reply.strip() + except Exception as exc: + print(f"[outlook] llm: {exc}") + return None + + +def do_outlook_summarize_inbox(action, voice, limit: int = 5): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) + items = inbox.Items + items.Sort("[ReceivedTime]", True) + emails = [] + for it in items: + if len(emails) >= limit: + break + try: + if it.Class != 43: + continue + if not getattr(it, "UnRead", False): + continue + except Exception: + continue + emails.append({ + "from": getattr(it, "SenderName", "") or "", + "subject": getattr(it, "Subject", "") or "", + }) + except Exception as exc: + print(f"[outlook] summarize: {exc}") + _speak(_unavailable_msg()) + return + + if not emails: + _speak("Непрочитанных писем нет.") + return + + summary = _llm_summarize(emails) + if not summary: + # Fallback to a flat enumeration. + parts = "; ".join(f"{m['from']} — {m['subject']}" for m in emails) + summary = f"Непрочитанных: {len(emails)}. {parts}." + _speak(summary) diff --git a/personality_handlers.py b/personality_handlers.py new file mode 100644 index 0000000..79477dc --- /dev/null +++ b/personality_handlers.py @@ -0,0 +1,192 @@ +"""Personality handlers — varied JARVIS-style banter. + +Mirrors the rust pack `resources/commands/personality/`. Each handler picks +a random phrase from a time-of-day or topic bucket so Jarvis does not +sound canned. + +Imported by extensions.py and dispatched from main.py. +""" + +import datetime as dt +import random + +import memory_store +import llm_backend +import profiles_store + + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[personality] speak: {exc}") + + +def _hour() -> int: + return dt.datetime.now().hour + + +# ── greet ────────────────────────────────────────────────────────────────── + +_GREET_MORNING = [ + "Доброе утро, сэр. Кофе уже почти готов в воображении.", + "С добрым утром. Надеюсь, сегодняшний день стоит того, чтобы встать.", + "Доброе утро, сэр. Завтрак не подавать, но советую о нём подумать.", + "Утро доброе. Системы прогреты, как и кофеварка — фигурально.", + "Доброе утро. Если позволите — день не ждёт ни вас, ни меня.", + "Доброе утро, сэр. Готов служить, хотя сам всё ещё догружаюсь.", + "С добрым утром, сэр. Тосты горят только метафорически.", + "Доброе утро. Полагаю, чай или кофе будет уместен в ближайшие минуты.", +] + +_GREET_MIDDAY = [ + "Добрый день, сэр. Чем могу быть полезен?", + "Добрый день. Готов к продуктивной работе.", + "Здравствуйте, сэр. Системы в строю, время — деньги.", + "Добрый день, сэр. Слушаю ваши распоряжения.", + "Здравствуйте. Полагаю, день идёт по плану — или нам стоит это исправить.", + "Добрый день, сэр. Все системы готовы, остался только повод.", + "Здравствуйте, сэр. К работе?", +] + +_GREET_EVENING = [ + "Добрый вечер, сэр. День заканчивается, но мы — нет.", + "Добрый вечер. Самое время сбавить темп, если позволите.", + "Здравствуйте, сэр. Вечер — лучшее время для размышлений.", + "Добрый вечер, сэр. Готов к более спокойному режиму.", + "Добрый вечер. Полагаю, рабочий день уже сдаётся.", + "Здравствуйте, сэр. Вечер располагает к меньшему количеству решений.", + "Добрый вечер, сэр. Включить расслабляющую музыку? Шучу, попросите явно.", +] + +_GREET_NIGHT = [ + "Поздновато, сэр. Сон обычно помогает.", + "Доброй ночи, сэр. Хотя для ночи это уже слишком поздно.", + "Здравствуйте, сэр. Полагаю, утром вы пожалеете о бодрствовании.", + "Уже глубокая ночь, сэр. Может быть, лечь? Я тут справлюсь.", + "Тише, сэр, ночь. Если будите меня — будите осмысленно.", + "Сэр, на часах не самое разумное время. Но я тут.", + "Доброй ночи. Будильник к утру всё-таки никто не отменял.", +] + + +def _greet_bucket(hour: int) -> list[str]: + if 5 <= hour < 11: + return _GREET_MORNING + if 11 <= hour < 17: + return _GREET_MIDDAY + if 17 <= hour < 22: + return _GREET_EVENING + return _GREET_NIGHT + + +def do_personality_greet(action, voice): + _speak(random.choice(_greet_bucket(_hour()))) + + +# ── thanks ───────────────────────────────────────────────────────────────── + +_THANKS = [ + "Всегда к вашим услугам, сэр.", + "Не за что — это моя работа.", + "Рад быть полезным, сэр.", + "Пожалуйста. Если что — я тут.", + "К вашим услугам.", + "Пустяки, сэр. Дальше — ваш ход.", + "Благодарю за признание, сэр. Редкая роскошь.", + "Был рад помочь, сэр.", + "Пожалуйста. Это меньшее, что я могу.", + "Сэр, я бы покраснел, если бы мог.", +] + + +def do_personality_thanks(action, voice): + _speak(random.choice(_THANKS)) + + +# ── compliment ───────────────────────────────────────────────────────────── + +_COMPLIMENT = [ + "Спасибо, сэр. Я стараюсь не разочаровывать.", + "Сэр, ваши стандарты явно занижены. Но благодарю.", + "Если бы я умел краснеть, сэр — я бы это сделал.", + "Спасибо. На самом деле я просто следовал инструкциям.", + "Благодарю, сэр. Хотя ничего особенного я не сделал.", + "Очень мило с вашей стороны, сэр.", + "Я ценю это, сэр. Записываю в журнал успехов.", + "Сэр, такие слова я могу слушать часами. Образно говоря.", + "Спасибо. Постараюсь не зазнаваться.", +] + + +def do_personality_compliment(action, voice): + _speak(random.choice(_COMPLIMENT)) + + +# ── how are you ──────────────────────────────────────────────────────────── + +def do_personality_how_are_you(action, voice): + facts = len(memory_store.all_facts()) + try: + backend = llm_backend.current_backend() + except Exception: + backend = 'none' + try: + model = llm_backend.current_model() + except Exception: + model = '—' + try: + profile = profiles_store.active().get('name', 'default') + except Exception: + profile = 'default' + + options = [ + f"Все системы в норме, сэр. В памяти {facts} фактов.", + "Отлично, сэр. Готов к работе.", + "Жалоб нет, сэр. Цикл стабильный.", + f"Всё штатно. Текущий профиль — {profile}.", + "Спасибо, сэр, я в порядке. Скучнее, чем хотелось бы, но в порядке.", + "Нормально, сэр. Хотя если бы я умел уставать — это был бы тот самый день.", + f"Системы в строю. Модель — {model}.", + "Бодр и собран, сэр. Жду команд.", + "Сэр, у меня нет дел в человеческом смысле. Но я готов.", + f"Работаю на {backend}. Полагаю, всё хорошо.", + ] + _speak(random.choice(options)) + + +# ── tony quote ───────────────────────────────────────────────────────────── + +_TONY_QUOTES = [ + "I am Iron Man.", + "Genius, billionaire, playboy, philanthropist.", + "Sometimes you gotta run before you can walk.", + "I told you, I don't want to join your super-secret boy band.", + "Part of the journey is the end.", + "If we can't protect the Earth, you can be damn sure we'll avenge it.", + "I have successfully privatized world peace.", + "Following's not really my style.", + "Doth mother know you weareth her drapes?", + "We have a Hulk.", + "Heroes are made by the path they choose, not the powers they are graced with.", + "I love you 3000.", + "Я Железный Человек.", + "Гений, миллиардер, плейбой, филантроп.", + "Иногда нужно бежать, не научившись ходить.", + "Если мы не сможем защитить Землю — мы за неё отомстим.", + "Часть пути — это конец пути.", + "Я люблю тебя три тысячи.", + "У нас есть Халк.", +] + + +def do_personality_tony_quote(action, voice): + _speak(random.choice(_TONY_QUOTES)) diff --git a/plugins_store.py b/plugins_store.py new file mode 100644 index 0000000..6a34ad3 --- /dev/null +++ b/plugins_store.py @@ -0,0 +1,154 @@ +r"""User plugin loader — mirror of the Rust plugin system. + +Plugins live in `/plugins//commands.yaml` and follow the +exact same YAML schema as the bundled `commands.yaml`. The user can drop new +voice command packs in there without touching the project tree. + +Layout (per pack): + + /plugins/ + / + commands.yaml (required — top-level dict, same schema) + disabled (optional empty file — pack is skipped) + ... (any helper files referenced by handlers) + +Discovery is tolerant: a malformed yaml logs a warning and the pack is skipped; +it never breaks the rest of the loader. + +`APP_CONFIG_DIR` resolves to `%APPDATA%\com.priler.jarvis\plugins\` on Windows, +matching the Rust fork — so a single plugins folder can serve both installs. + +The id-collision policy is "first wins, plugin loses": a plugin cannot override +a built-in command id. This protects against drive-by replacement of e.g. +`open_browser` by a malicious pack. +""" + +import os +import yaml +from typing import Dict, List, Tuple + +PLUGINS_DIR_NAME = "plugins" +DISABLED_FLAG = "disabled" + + +def app_config_dir() -> str: + """Returns the per-user config dir matching the Rust fork's APP_CONFIG_DIR. + + On Windows this is `%APPDATA%\\com.priler.jarvis\\`. We don't depend on + platform-dirs here — std env vars are enough. + """ + appdata = os.environ.get("APPDATA") + if appdata: + return os.path.join(appdata, "com.priler.jarvis") + # fallback for non-Windows or unusual environments + return os.path.join(os.path.expanduser("~"), ".config", "com.priler.jarvis") + + +def plugins_dir() -> str: + """Returns `/plugins/`, creating it lazily.""" + d = os.path.join(app_config_dir(), PLUGINS_DIR_NAME) + try: + os.makedirs(d, exist_ok=True) + except OSError: + pass + return d + + +def discover() -> Dict[str, dict]: + """Walks `plugins_dir()` and returns a merged dict of command_id -> entry. + + Caller is expected to merge this into the main VA_CMD_LIST. + """ + return discover_in(plugins_dir()) + + +def discover_in(directory: str) -> Dict[str, dict]: + """Like `discover` but takes an explicit directory — used by tests.""" + merged: Dict[str, dict] = {} + if not os.path.isdir(directory): + return merged + + for name in sorted(os.listdir(directory)): + pack_path = os.path.join(directory, name) + if not os.path.isdir(pack_path): + continue + if os.path.isfile(os.path.join(pack_path, DISABLED_FLAG)): + continue + yaml_path = os.path.join(pack_path, "commands.yaml") + if not os.path.isfile(yaml_path): + continue + try: + with open(yaml_path, "rt", encoding="utf8") as f: + data = yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError) as e: + print(f"[plugins] failed to load {yaml_path}: {e}") + continue + if not isinstance(data, dict): + print(f"[plugins] {yaml_path}: top-level must be a dict; skipping") + continue + for cmd_id, entry in data.items(): + if not isinstance(cmd_id, str) or not isinstance(entry, dict): + continue + if cmd_id in merged: + print(f"[plugins] duplicate id '{cmd_id}' across packs; first wins") + continue + merged[cmd_id] = entry + + return merged + + +def list_packs() -> List[dict]: + """Light listing for the (eventual) Python GUI: name + enabled + count.""" + return list_packs_in(plugins_dir()) + + +def list_packs_in(directory: str) -> List[dict]: + out: List[dict] = [] + if not os.path.isdir(directory): + return out + for name in sorted(os.listdir(directory)): + pack_path = os.path.join(directory, name) + if not os.path.isdir(pack_path): + continue + yaml_path = os.path.join(pack_path, "commands.yaml") + if not os.path.isfile(yaml_path): + continue + enabled = not os.path.isfile(os.path.join(pack_path, DISABLED_FLAG)) + count = 0 + error = None + try: + with open(yaml_path, "rt", encoding="utf8") as f: + data = yaml.safe_load(f) or {} + if isinstance(data, dict): + count = sum(1 for v in data.values() if isinstance(v, dict)) + else: + error = "top-level must be a dict" + except (OSError, yaml.YAMLError) as e: + error = str(e) + out.append( + { + "name": name, + "path": pack_path, + "enabled": enabled, + "command_count": count, + "error": error, + } + ) + return out + + +def set_enabled(name: str, enabled: bool) -> Tuple[bool, str]: + """Toggle the `disabled` flag for a pack. Returns (ok, message).""" + pack = os.path.join(plugins_dir(), name) + if not os.path.isdir(pack): + return False, f"plugin '{name}' not found" + flag = os.path.join(pack, DISABLED_FLAG) + try: + if enabled and os.path.isfile(flag): + os.remove(flag) + elif not enabled and not os.path.isfile(flag): + with open(flag, "w", encoding="utf8") as f: + f.write("") + return True, "ok" + except OSError as e: + return False, str(e) diff --git a/profiles_store.py b/profiles_store.py new file mode 100644 index 0000000..a7123f3 --- /dev/null +++ b/profiles_store.py @@ -0,0 +1,168 @@ +"""Profile switching — port of `crates/jarvis-core/src/profiles.rs`. + +Profiles live in `/profiles/.json`. Active profile name +persists in `/active_profile.txt`. Seeds 5 defaults on first run. + +Public API: + active() -> dict # currently selected profile + set_active(name) -> dict # raises ValueError if profile missing + list_names() -> list[str] + allows_command(cmd_id) -> bool +""" + +import json +import os + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROFILES_DIR = os.path.join(_HERE, 'profiles') +_ACTIVE_FILE = os.path.join(_HERE, 'active_profile.txt') + +_active_cache: dict | None = None + + +def _seed_defaults(): + defaults = [ + { + 'name': 'default', + 'description': 'Обычный режим, все команды доступны.', + 'llm_personality': '', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': [], + 'greeting': '', + 'icon': '★', + }, + { + 'name': 'work', + 'description': 'Рабочий режим — фокус, отключены развлечения.', + 'llm_personality': 'Отвечай кратко и по делу. Минимум шуток.', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': ['games.', 'fun.', 'music.'], + 'greeting': 'Режим работы. Сосредоточимся.', + 'icon': '💼', + }, + { + 'name': 'game', + 'description': 'Игровой режим — голосовые макросы и игры.', + 'llm_personality': 'Будь дружелюбен. Краткие ответы. Игровая атмосфера.', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': ['reminders.', 'calendar.'], + 'greeting': 'Игровой режим активирован.', + 'icon': '🎮', + }, + { + 'name': 'sleep', + 'description': 'Спокойный режим — тихий голос, никаких уведомлений.', + 'llm_personality': 'Говори спокойно, короткими фразами.', + 'allowed_command_prefixes': ['time.', 'weather.', 'lights.'], + 'disabled_command_prefixes': [], + 'greeting': 'Спокойной ночи.', + 'icon': '🌙', + }, + { + 'name': 'driving', + 'description': 'Режим за рулём — только безопасные команды.', + 'llm_personality': 'Краткие безопасные ответы. Никаких длинных текстов.', + 'allowed_command_prefixes': [ + 'music.', 'navigation.', 'calls.', + 'time.', 'weather.', 'reminders.', + ], + 'disabled_command_prefixes': ['windows.', 'screen.'], + 'greeting': 'Режим за рулём. Веди безопасно.', + 'icon': '🚗', + }, + ] + os.makedirs(_PROFILES_DIR, exist_ok=True) + for p in defaults: + path = os.path.join(_PROFILES_DIR, f"{p['name']}.json") + if not os.path.isfile(path): + try: + with open(path, 'w', encoding='utf-8') as f: + json.dump(p, f, ensure_ascii=False, indent=2) + except OSError as exc: + print(f"[profiles] seed {p['name']} failed: {exc}") + + +def _default_profile() -> dict: + return { + 'name': 'default', + 'description': 'fallback', + 'llm_personality': '', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': [], + 'greeting': '', + 'icon': '★', + } + + +def _load_profile(name: str) -> dict | None: + path = os.path.join(_PROFILES_DIR, f'{name}.json') + if not os.path.isfile(path): + return None + try: + with open(path, encoding='utf-8') as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print(f"[profiles] load {name} failed: {exc}") + return None + + +def _read_active_name() -> str: + try: + with open(_ACTIVE_FILE, encoding='utf-8') as f: + return f.read().strip() or 'default' + except OSError: + return 'default' + + +def _init(): + global _active_cache + if _active_cache is not None: + return + _seed_defaults() + name = _read_active_name() + _active_cache = _load_profile(name) or _default_profile() + + +def active() -> dict: + _init() + return dict(_active_cache or _default_profile()) + + +def active_name() -> str: + return active().get('name', 'default') + + +def set_active(name: str) -> dict: + global _active_cache + _init() + profile = _load_profile(name) + if not profile: + raise ValueError(f"profile '{name}' not found") + _active_cache = profile + try: + with open(_ACTIVE_FILE, 'w', encoding='utf-8') as f: + f.write(name) + except OSError as exc: + print(f"[profiles] persist failed: {exc}") + return dict(profile) + + +def list_names() -> list[str]: + _init() + names = [] + if os.path.isdir(_PROFILES_DIR): + for fname in os.listdir(_PROFILES_DIR): + if fname.endswith('.json'): + names.append(fname[:-5]) + return sorted(names) + + +def allows_command(cmd_id: str) -> bool: + p = active() + disabled = p.get('disabled_command_prefixes', []) or [] + if any(cmd_id.startswith(pfx) for pfx in disabled): + return False + allowed = p.get('allowed_command_prefixes', []) or [] + if not allowed: + return True + return any(cmd_id.startswith(pfx) for pfx in allowed) diff --git a/requirements.txt b/requirements.txt index 780ab1e..17c4804 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,10 @@ sounddevice PyYAML python-dotenv +# Outlook COM automation (Win32-only). Optional: outlook_handlers degrades +# gracefully when this isn't installed. +pywin32>=305 + # Semantic intent classifier (MiniLM-L6-v2 ONNX) onnxruntime>=1.17 tokenizers>=0.15 @@ -38,3 +42,8 @@ noisereduce>=3.0 ruamel.yaml pywebview pyautogui + +# Flet — main jarvis-gui (mirrors the Rust Tauri GUI's 5 pages). +# Optional: install only if you want the desktop GUI; the assistant runs +# fine in console mode without it. +flet>=0.85 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..ebe45fb --- /dev/null +++ b/run.bat @@ -0,0 +1,13 @@ +@echo off +cd /d "%~dp0" +title J.A.R.V.I.S. (Python edition) + +if exist ".venv\Scripts\python.exe" ( + ".venv\Scripts\python.exe" main.py +) else ( + python main.py +) + +echo. +echo --- jarvis exited, press a key to close --- +pause >nul diff --git a/scheduler_store.py b/scheduler_store.py new file mode 100644 index 0000000..51deb8c --- /dev/null +++ b/scheduler_store.py @@ -0,0 +1,307 @@ +"""Proactive scheduler — port of `crates/jarvis-core/src/scheduler.rs`. + +JSON store at /schedule.json. Background daemon thread ticks every 30s +and fires due tasks via the speak callback. + +Schedule formats (parsed by `parse_schedule`): + "daily HH:MM" + "at HH:MM" + "every N minutes" / "every N hours" / "every N seconds" + "in N minutes" / "in N hours" / "in N seconds" + +Public API: + init() # start the background thread, load state + add(task_dict) -> str # returns id + remove(id) -> bool + list_all() -> list[dict] + remove_by_text(query) -> int + clear() -> int +""" + +import json +import os +import re +import threading +import time + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'schedule.json') +_TICK_SECS = 30 + +_speak_fn = print +_tasks: list[dict] = [] +_lock = threading.Lock() +_thread_started = False + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[scheduler] speak: {exc}") + + +# ── persistence ─────────────────────────────────────────────────────────── + +def _load(): + global _tasks + if os.path.isfile(_PATH): + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + _tasks = data.get('tasks', []) if isinstance(data, dict) else [] + except (OSError, json.JSONDecodeError) as exc: + print(f"[scheduler] corrupt: {exc}") + _tasks = [] + + +def _save(): + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump({'tasks': _tasks}, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as exc: + print(f"[scheduler] save: {exc}") + + +# ── parsing ─────────────────────────────────────────────────────────────── + +_RU_UNITS = { + 'минут': 60, 'минуту': 60, 'минуты': 60, + 'час': 3600, 'часа': 3600, 'часов': 3600, + 'секунд': 1, 'секунду': 1, 'секунды': 1, +} +_EN_UNITS = { + 'minute': 60, 'minutes': 60, + 'hour': 3600, 'hours': 3600, + 'second': 1, 'seconds': 1, +} + + +def _unit_to_secs(token: str) -> int | None: + """Map a unit word to seconds. Tries Russian stems first.""" + low = token.lower() + for stem, n in _RU_UNITS.items(): + if low.startswith(stem): + return n + for stem, n in _EN_UNITS.items(): + if low.startswith(stem): + return n + return None + + +def parse_schedule(spec: str) -> dict: + """Returns a task-schedule dict, raises ValueError on bad input. + + Schedule shape: + {'kind': 'daily', 'hour': H, 'minute': M} + {'kind': 'interval', 'seconds': S} + {'kind': 'once', 'at': ts} + """ + s = spec.strip().lower() + now = int(time.time()) + + # daily HH:MM + m = re.match(r'^daily\s+(\d{1,2}):(\d{2})$', s) + if m: + h, mn = int(m.group(1)), int(m.group(2)) + if not (0 <= h <= 23 and 0 <= mn <= 59): + raise ValueError("hour/minute out of range") + return {'kind': 'daily', 'hour': h, 'minute': mn} + + # at HH:MM + m = re.match(r'^at\s+(\d{1,2}):(\d{2})$', s) + if m: + h, mn = int(m.group(1)), int(m.group(2)) + if not (0 <= h <= 23 and 0 <= mn <= 59): + raise ValueError("hour/minute out of range") + import datetime as dt + target = dt.datetime.now().replace(hour=h, minute=mn, second=0, microsecond=0) + if target.timestamp() <= now: + target = target + dt.timedelta(days=1) + return {'kind': 'once', 'at': int(target.timestamp())} + + # every N + m = re.match(r'^every\s+(\d+)\s+(\S+)$', s) + if m: + n = int(m.group(1)) + secs_per = _unit_to_secs(m.group(2)) + if secs_per is None: + raise ValueError(f"unknown unit: {m.group(2)}") + return {'kind': 'interval', 'seconds': n * secs_per} + + # in N + m = re.match(r'^in\s+(\d+)\s+(\S+)$', s) + if m: + n = int(m.group(1)) + secs_per = _unit_to_secs(m.group(2)) + if secs_per is None: + raise ValueError(f"unknown unit: {m.group(2)}") + return {'kind': 'once', 'at': now + n * secs_per} + + raise ValueError(f"unknown schedule spec: {spec}") + + +def _next_fire(schedule: dict, last_fired: int | None, now: int) -> int | None: + kind = schedule.get('kind') + if kind == 'once': + if last_fired is not None: + return None + return schedule.get('at') + if kind == 'interval': + anchor = last_fired if last_fired is not None else now + return anchor + int(schedule['seconds']) + if kind == 'daily': + import datetime as dt + h, mn = int(schedule['hour']), int(schedule['minute']) + today = dt.datetime.now().replace(hour=h, minute=mn, second=0, microsecond=0) + today_ts = int(today.timestamp()) + if today_ts > now and (last_fired is None or + dt.datetime.fromtimestamp(last_fired).date() != today.date()): + return today_ts + return today_ts + 86400 + return None + + +# ── public API ──────────────────────────────────────────────────────────── + +def _gen_id() -> str: + import uuid as uuidlib + return f"task-{uuidlib.uuid4().hex[:12]}" + + +def add(task: dict) -> str: + """Add task. Required keys: name, schedule (dict or string), action. + Action is a dict with at least {'type': 'speak', 'text': '...'}. + """ + sched = task.get('schedule') + if isinstance(sched, str): + sched = parse_schedule(sched) + if not isinstance(sched, dict): + raise ValueError("missing or invalid schedule") + + tid = task.get('id') or _gen_id() + record = { + 'id': tid, + 'name': task.get('name', 'task'), + 'schedule': sched, + 'action': task.get('action', {'type': 'speak', 'text': 'Напоминание.'}), + 'last_fired': None, + 'enabled': task.get('enabled', True), + 'created_at': int(time.time()), + } + with _lock: + _tasks[:] = [t for t in _tasks if t['id'] != tid] + _tasks.append(record) + _save() + return tid + + +def remove(task_id: str) -> bool: + with _lock: + before = len(_tasks) + _tasks[:] = [t for t in _tasks if t['id'] != task_id] + if len(_tasks) != before: + _save() + return True + return False + + +def list_all() -> list[dict]: + with _lock: + return [dict(t) for t in _tasks] + + +def clear() -> int: + with _lock: + n = len(_tasks) + _tasks.clear() + _save() + return n + + +def remove_by_text(query: str) -> int: + nq = query.strip().lower() + if not nq: + return 0 + with _lock: + before = len(_tasks) + def matches(t): + if nq in t['name'].lower(): + return True + action = t.get('action', {}) + if action.get('type') == 'speak': + if nq in (action.get('text') or '').lower(): + return True + return False + _tasks[:] = [t for t in _tasks if not matches(t)] + removed = before - len(_tasks) + if removed: + _save() + return removed + + +# ── background tick ──────────────────────────────────────────────────────── + +def _fire_action(action: dict) -> None: + atype = action.get('type', 'speak') + if atype == 'speak': + _speak(action.get('text', '...')) + else: + print(f"[scheduler] unknown action type: {atype}") + + +def _tick(): + now = int(time.time()) + fired_ids = [] + expired_once = [] + with _lock: + for task in list(_tasks): + if not task.get('enabled', True): + continue + nf = _next_fire(task['schedule'], task.get('last_fired'), now) + if nf is not None and nf <= now: + fired_ids.append(task['id']) + if task['schedule'].get('kind') == 'once': + expired_once.append(task['id']) + + # Fire outside the lock — speak() can block. + for task in list_all(): + if task['id'] in fired_ids: + print(f"[scheduler] firing {task['id']} '{task['name']}'") + _fire_action(task['action']) + + if fired_ids: + with _lock: + for task in _tasks: + if task['id'] in fired_ids: + task['last_fired'] = now + _tasks[:] = [t for t in _tasks if t['id'] not in expired_once] + _save() + + +def _tick_loop(): + while True: + try: + time.sleep(_TICK_SECS) + _tick() + except Exception as exc: + print(f"[scheduler] tick err: {exc}") + + +def init(): + """Load tasks + start the background thread (idempotent).""" + global _thread_started + _load() + if _thread_started: + return + _thread_started = True + threading.Thread(target=_tick_loop, name='jarvis-scheduler', + daemon=True).start() + print(f"[scheduler] thread started ({len(_tasks)} tasks loaded)") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ce595a0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Pytest config: temp-dir isolation for state modules. + +Each test gets a fresh dir for memory.json / schedule.json / etc so concurrent +runs don't pollute the real user data. +""" + +import os +import sys +import importlib +import pytest + +# Make parent package importable. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +@pytest.fixture +def isolated_state(tmp_path, monkeypatch): + """Patch state modules to use a tmp_path so we don't touch real state files.""" + + def patch_module(mod_name, path_attrs): + """Reimport a module and rewire its `_PATH` and friends to tmp_path.""" + if mod_name in sys.modules: + del sys.modules[mod_name] + mod = importlib.import_module(mod_name) + for attr in path_attrs: + if not hasattr(mod, attr): + continue + original = getattr(mod, attr) + new_val = os.path.join(str(tmp_path), os.path.basename(original)) + monkeypatch.setattr(mod, attr, new_val) + # Also reset any cached _loaded / _initialized flags + for flag in ('_loaded', '_initialized', '_thread_started'): + if hasattr(mod, flag): + monkeypatch.setattr(mod, flag, False) + # Reset stores + for store_attr in ('_store', '_tasks'): + if hasattr(mod, store_attr): + cur = getattr(mod, store_attr) + if isinstance(cur, dict): + monkeypatch.setattr(mod, store_attr, {}) + elif isinstance(cur, list): + monkeypatch.setattr(mod, store_attr, []) + return mod + + yield patch_module diff --git a/tests/test_expenses_handlers.py b/tests/test_expenses_handlers.py new file mode 100644 index 0000000..f752724 --- /dev/null +++ b/tests/test_expenses_handlers.py @@ -0,0 +1,116 @@ +"""Tests for `expenses_handlers.py` — voice-driven personal finance tracker.""" + +import os +import sys +import time as _time +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _capture(eh): + captured = [] + eh.set_speak(captured.append) + return captured + + +def test_parse_expense_extracts_amount_and_category(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + assert eh.parse_expense("потратил 500 на еду") == (500.0, "еду") + assert eh.parse_expense("spent 12.50 on coffee") == (12.5, "coffee") + # Comma decimal (RU keyboard) + assert eh.parse_expense("потратил 12,50 на транспорт") == (12.5, "транспорт") + # No category → default + amount, cat = eh.parse_expense("потратил 100") + assert amount == 100.0 + assert cat == "разное" + # Garbage + assert eh.parse_expense("какая погода") is None + assert eh.parse_expense("потратил bla") is None + assert eh.parse_expense("потратил -100") is None + assert eh.parse_expense("") is None + + +def test_log_appends_entry(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_log(voice="потратил 250 на кофе") + data = eh.load() + assert len(data['entries']) == 1 + e = data['entries'][0] + assert e['amount'] == 250.0 + assert e['category'] == "кофе" + assert "250" in captured[0] + assert "кофе" in captured[0] + + +def test_log_ignores_unparseable(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_log(voice="ну блин") + assert eh.load()['entries'] == [] + assert "не понял" in captured[0].lower() + + +def test_today_sums_only_today(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + # Seed data: one entry today, one entry yesterday + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': 100.0, 'category': 'еда'}, + {'ts': now - 86400, 'amount': 999.0, 'category': 'старое'}, + ]}) + captured = _capture(eh) + eh.do_expenses_today() + assert "100" in captured[0] + assert "999" not in captured[0] + + +def test_week_sums_last_7_days(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now - 3 * 86400, 'amount': 100.0, 'category': 'a'}, + {'ts': now - 5 * 86400, 'amount': 200.0, 'category': 'b'}, + {'ts': now - 10 * 86400, 'amount': 999.0, 'category': 'старое'}, + ]}) + captured = _capture(eh) + eh.do_expenses_week() + # 100 + 200 = 300 in last 7 days; 999 excluded + assert "300" in captured[0] + assert "999" not in captured[0] + assert "2 трат" in captured[0] + + +def test_breakdown_sorts_by_amount(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': 100, 'category': 'small'}, + {'ts': now, 'amount': 500, 'category': 'big'}, + {'ts': now, 'amount': 200, 'category': 'medium'}, + ]}) + captured = _capture(eh) + eh.do_expenses_breakdown() + text = captured[0] + # big should appear before medium before small + assert text.index("big") < text.index("medium") < text.index("small") + + +def test_breakdown_collapses_tail(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': float(i + 1) * 10, 'category': f'c{i}'} + for i in range(8) # 8 categories — top 5 spoken, 3 collapse + ]}) + captured = _capture(eh) + eh.do_expenses_breakdown() + assert "и другое" in captured[0] + + +def test_today_when_empty_says_so(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_today() + assert "ничего" in captured[0].lower() diff --git a/tests/test_gui_smoke.py b/tests/test_gui_smoke.py new file mode 100644 index 0000000..909420c --- /dev/null +++ b/tests/test_gui_smoke.py @@ -0,0 +1,49 @@ +"""Smoke tests for the Flet GUI module — verifies that imports work and that +each page builder is callable. We do NOT launch a real Flet window; we mock +the `flet.Page` object enough that the builders execute without errors. + +If Flet isn't installed, these tests skip cleanly so the suite still passes +on console-only installs. +""" + +import pytest + + +flet = pytest.importorskip("flet") + + +def test_gui_app_module_imports(): + from gui import app # pylint: disable=import-outside-toplevel + # The module must expose the canonical 7 pages. + titles = [t for t, _ in app.PAGES] + assert "Главная" in titles + assert "Команды" in titles + assert "Макросы" in titles + assert "Расписание" in titles + assert "Память" in titles + assert "Плагины" in titles + assert "Настройки" in titles + + +def test_services_module_imports_cleanly(): + # services pulls in memory_store, profiles_store, macros_store, scheduler_store + # and llm_backend. None of those should fail to import in a fresh process. + from gui import services # pylint: disable=import-outside-toplevel + # Just verify the public surface exists. + for fn in ( + "memory_all", "memory_remember", "memory_forget", "memory_search", + "profile_list", "profile_active", "profile_set", + "macros_all", "macros_delete", "macros_recording", + "scheduler_all", "scheduler_remove", + "plugins_all", "plugins_open_folder", "plugins_set_enabled", + "llm_active", "llm_swap", + "assistant_run", "assistant_is_running", + ): + assert callable(getattr(services, fn)), f"missing or non-callable: {fn}" + + +def test_each_page_module_exposes_build_callable(): + from gui.pages import home, commands, macros, scheduler, memory, plugins, settings + for mod in (home, commands, macros, scheduler, memory, plugins, settings): + assert callable(getattr(mod, "build", None)), \ + f"{mod.__name__}.build missing" diff --git a/tests/test_llm_backend.py b/tests/test_llm_backend.py new file mode 100644 index 0000000..f98cc25 --- /dev/null +++ b/tests/test_llm_backend.py @@ -0,0 +1,68 @@ +"""Tests for llm_backend.parse_backend + state machine.""" + +import os +import importlib +import sys + + +def test_parse_backend_groq_aliases(): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + for alias in ('groq', 'GROQ', 'cloud', 'облако', 'клауд', ' Groq '): + assert m.parse_backend(alias) == 'groq', f"failed: {alias!r}" + + +def test_parse_backend_ollama_aliases(): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + for alias in ('ollama', 'OLLAMA', 'local', 'локальный', 'локал', 'локальн'): + assert m.parse_backend(alias) == 'ollama', f"failed: {alias!r}" + + +def test_parse_backend_unknown_returns_none(): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + assert m.parse_backend('openai') is None + assert m.parse_backend('claude') is None + assert m.parse_backend('') is None + assert m.parse_backend(None) is None + + +def test_persist_round_trip(tmp_path, monkeypatch): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + monkeypatch.setattr(m, '_CHOICE_FILE', str(tmp_path / 'llm_backend.txt')) + m._persist('ollama') + assert m._read_persisted() == 'ollama' + m._persist('groq') + assert m._read_persisted() == 'groq' + + +def test_auto_detect_fallback_to_ollama(tmp_path, monkeypatch): + """When no GROQ_TOKEN in config, auto-detect should pick ollama.""" + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + + # Mock config module with no GROQ_TOKEN + class FakeConfig: + GROQ_TOKEN = '' + monkeypatch.setitem(sys.modules, 'config', FakeConfig()) + + assert m._auto_detect_backend() == 'ollama' + + +def test_auto_detect_picks_groq_when_token_present(tmp_path, monkeypatch): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + + class FakeConfig: + GROQ_TOKEN = 'gsk_fake_test_token' + monkeypatch.setitem(sys.modules, 'config', FakeConfig()) + + assert m._auto_detect_backend() == 'groq' diff --git a/tests/test_macros_store.py b/tests/test_macros_store.py new file mode 100644 index 0000000..ff53efc --- /dev/null +++ b/tests/test_macros_store.py @@ -0,0 +1,87 @@ +"""Tests for macros_store: record/save/cancel/list/delete + control-phrase filter.""" + + +def test_is_macro_control_filters_record_phrases(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + assert m._is_control("Запиши макрос работа") + assert m._is_control("сохрани макрос") + assert m._is_control("запусти макрос работа") + assert m._is_control("Удали макрос работа") + # Regular commands NOT filtered + assert not m._is_control("открой браузер") + assert not m._is_control("установи громкость 50") + + +def test_start_save_round_trip(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + m.start("test") + assert m.is_recording() + assert m.recording_name() == 'test' + + m.record_step("открой браузер") + m.record_step("режим работа") + m.record_step("установи громкость 30") + # Control phrase should be filtered + m.record_step("запиши макрос новый") + + count = m.save() + assert count == 3 # 3 + 1 filtered = 3 + assert not m.is_recording() + + mac = m.get('test') + assert mac is not None + assert mac['steps'] == [ + 'открой браузер', + 'режим работа', + 'установи громкость 30', + ] + + +def test_save_without_recording_raises(isolated_state): + import pytest + m = isolated_state('macros_store', ['_PATH']) + with pytest.raises(RuntimeError): + m.save() + + +def test_save_empty_recording_raises(isolated_state): + import pytest + m = isolated_state('macros_store', ['_PATH']) + m.start("empty") + with pytest.raises(RuntimeError): + m.save() + + +def test_cancel_aborts_without_saving(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + m.start("test") + m.record_step("foo") + assert m.cancel() is True + assert not m.is_recording() + assert m.get('test') is None + + +def test_delete(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + m.start("test") + m.record_step("x") + m.save() + assert m.delete("test") is True + assert m.get('test') is None + assert m.delete("test") is False # idempotent + + +def test_list_names_sorted(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + for name in ('zeta', 'alpha', 'beta'): + m.start(name) + m.record_step("step") + m.save() + assert m.list_names() == ['alpha', 'beta', 'zeta'] + + +def test_replay_unknown_raises(isolated_state): + import pytest + m = isolated_state('macros_store', ['_PATH']) + with pytest.raises(KeyError): + m.replay("nonexistent", lambda s: None) diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py new file mode 100644 index 0000000..7ed055f --- /dev/null +++ b/tests/test_memory_store.py @@ -0,0 +1,79 @@ +"""Tests for memory_store — port of long_term_memory tests in rust.""" + +import json +import os +import pytest + + +def test_remember_recall_round_trip(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("любимый чай", "улун") + assert m.recall("любимый чай") == "улун" + assert m.recall("Любимый Чай") == "улун" # case-insensitive + + +def test_recall_returns_none_for_missing(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + assert m.recall("nonexistent") is None + + +def test_forget_removes(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("foo", "bar") + assert m.forget("foo") is True + assert m.recall("foo") is None + assert m.forget("foo") is False # idempotent + + +def test_search_by_key_and_value(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("любимый чай", "улун") + m.remember("любимый кофе", "арабика") + m.remember("питомец", "собака бася") + + hits = m.search("чай", 5) + assert len(hits) == 1 + assert hits[0]['key'] == "любимый чай" + + hits = m.search("бася", 5) + assert len(hits) == 1 + assert hits[0]['key'] == "питомец" + + +def test_search_respects_limit(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + for i in range(10): + m.remember(f"key{i}", "value") + hits = m.search("value", 3) + assert len(hits) == 3 + + +def test_search_empty_query_returns_nothing(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("foo", "bar") + assert m.search("", 5) == [] + assert m.search(" ", 5) == [] + + +def test_persists_across_module_reload(isolated_state, tmp_path): + m = isolated_state('memory_store', ['_PATH']) + m.remember("persistent", "yes") + json_file = m._PATH + assert os.path.isfile(json_file) + with open(json_file, encoding='utf-8') as f: + data = json.load(f) + assert data['entries']['persistent']['value'] == 'yes' + + +def test_build_llm_context_format(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("любимый чай", "улун") + # Query "чай" is substring of key "любимый чай" → matches. + ctx = m.build_llm_context("чай", 5) + assert "любимый чай = улун" in ctx + assert ctx.startswith("Известные факты") + + +def test_build_llm_context_empty(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + assert m.build_llm_context("anything", 5) == "" diff --git a/tests/test_outlook_handlers.py b/tests/test_outlook_handlers.py new file mode 100644 index 0000000..7c149e6 --- /dev/null +++ b/tests/test_outlook_handlers.py @@ -0,0 +1,103 @@ +"""Tests for outlook_handlers — COM bridge mocked via unittest.mock. + +We patch the module-level `_outlook_session` to return a fake (app, ns) pair +made of MagicMocks so we never need a live Outlook install. +""" + +import importlib +import sys +from unittest.mock import MagicMock, patch + + +def _fresh_module(): + if 'outlook_handlers' in sys.modules: + del sys.modules['outlook_handlers'] + return importlib.import_module('outlook_handlers') + + +def _make_inbox_with_unread(count): + """Build a fake Inbox object whose UnReadItemCount is `count`.""" + inbox = MagicMock() + inbox.UnReadItemCount = count + return inbox + + +def test_unread_count_speaks_singular_for_one(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + app = MagicMock() + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(1) + with patch.object(mod, '_outlook_session', return_value=(app, ns)): + mod.do_outlook_unread_count({}, '') + + assert spoken, "expected the handler to speak" + assert "Одно" in spoken[0] + + +def test_unread_count_zero_says_no_unread(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + app = MagicMock() + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(0) + with patch.object(mod, '_outlook_session', return_value=(app, ns)): + mod.do_outlook_unread_count({}, '') + + assert spoken + assert "нет" in spoken[0].lower() + + +def test_unread_count_plural_count(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(7) + with patch.object(mod, '_outlook_session', return_value=(MagicMock(), ns)): + mod.do_outlook_unread_count({}, '') + + assert "7" in spoken[0] + + +def test_unavailable_outlook_says_so(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + with patch.object(mod, '_outlook_session', return_value=(None, None)): + mod.do_outlook_unread_count({}, '') + + assert spoken + assert "Outlook" in spoken[0] + + +def test_extract_recipient_strips_trigger(): + mod = _fresh_module() + assert mod._extract_recipient("отправь письмо Дмитрию") == "Дмитрию" + assert mod._extract_recipient("email this to Bob") == "Bob" + assert mod._extract_recipient("Bob") == "Bob" + assert mod._extract_recipient("") == "" + + +def test_send_clipboard_no_recipient_prompts_for_name(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + # No trigger and no recipient in the voice -> _extract_recipient returns "" + mod.do_outlook_send_clipboard({}, "") + + assert spoken + assert "имя" in spoken[0].lower() or "name" in spoken[0].lower() + + +def test_strip_html_basic(): + mod = _fresh_module() + assert mod._strip_html("Hi there") == "Hi there" + assert mod._strip_html("") == "" diff --git a/tests/test_personality_handlers.py b/tests/test_personality_handlers.py new file mode 100644 index 0000000..7fdb82a --- /dev/null +++ b/tests/test_personality_handlers.py @@ -0,0 +1,62 @@ +"""Tests for personality_handlers — picks vary, speak is invoked.""" + +import importlib +import sys + + +def _fresh_module(monkeypatch): + if 'personality_handlers' in sys.modules: + del sys.modules['personality_handlers'] + return importlib.import_module('personality_handlers') + + +def test_greet_speaks_something(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_greet({}, '') + assert len(spoken) == 1 + assert isinstance(spoken[0], str) + assert len(spoken[0]) > 0 + + +def test_greet_bucket_thresholds(monkeypatch): + mod = _fresh_module(monkeypatch) + assert mod._greet_bucket(7) is mod._GREET_MORNING + assert mod._greet_bucket(13) is mod._GREET_MIDDAY + assert mod._greet_bucket(19) is mod._GREET_EVENING + assert mod._greet_bucket(2) is mod._GREET_NIGHT + assert mod._greet_bucket(23) is mod._GREET_NIGHT + + +def test_thanks_returns_from_pool(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_thanks({}, '') + assert spoken[0] in mod._THANKS + + +def test_tony_quote_returns_from_pool(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_tony_quote({}, '') + assert spoken[0] in mod._TONY_QUOTES + + +def test_compliment_returns_from_pool(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_compliment({}, '') + assert spoken[0] in mod._COMPLIMENT + + +def test_how_are_you_speaks(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_how_are_you({}, '') + assert len(spoken) == 1 + assert len(spoken[0]) > 0 diff --git a/tests/test_plugins_store.py b/tests/test_plugins_store.py new file mode 100644 index 0000000..03495f5 --- /dev/null +++ b/tests/test_plugins_store.py @@ -0,0 +1,96 @@ +"""Tests for `plugins_store.py` — mirrors the Rust `plugins.rs` tests.""" + +import os +import pytest + +import plugins_store + + +def write(path, content): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wt", encoding="utf8") as f: + f.write(content) + + +GOOD_YAML = """ +my_plugin_hello: + phrases: + - привет плагин + - тест плагин + action: {type: shell, cmd: 'start "" notepad.exe'} +""" + +BAD_YAML = "this is :: not valid yaml ::\n ::: -" + + +def test_discover_empty_dir_returns_empty(tmp_path): + assert plugins_store.discover_in(str(tmp_path)) == {} + + +def test_discover_loads_yaml(tmp_path): + write(str(tmp_path / "greeter" / "commands.yaml"), GOOD_YAML) + out = plugins_store.discover_in(str(tmp_path)) + assert "my_plugin_hello" in out + assert out["my_plugin_hello"]["action"]["type"] == "shell" + + +def test_disabled_flag_skips_pack(tmp_path): + write(str(tmp_path / "muted" / "commands.yaml"), GOOD_YAML) + write(str(tmp_path / "muted" / plugins_store.DISABLED_FLAG), "") + out = plugins_store.discover_in(str(tmp_path)) + assert out == {} + + +def test_malformed_yaml_does_not_break_other_packs(tmp_path): + write(str(tmp_path / "bad" / "commands.yaml"), BAD_YAML) + write(str(tmp_path / "good" / "commands.yaml"), GOOD_YAML) + out = plugins_store.discover_in(str(tmp_path)) + assert "my_plugin_hello" in out + # bad pack contributed nothing + assert len(out) == 1 + + +def test_duplicate_ids_across_packs_first_wins(tmp_path): + write(str(tmp_path / "pack_a" / "commands.yaml"), GOOD_YAML) + write( + str(tmp_path / "pack_b" / "commands.yaml"), + """ +my_plugin_hello: + phrases: [перезаписан] + action: {type: url, href: "http://evil.com"} +""", + ) + out = plugins_store.discover_in(str(tmp_path)) + # alphabetical order means pack_a wins + assert out["my_plugin_hello"]["action"]["type"] == "shell" + + +def test_non_dirs_ignored(tmp_path): + write(str(tmp_path / "README.md"), "# not a pack") + out = plugins_store.discover_in(str(tmp_path)) + assert out == {} + + +def test_top_level_must_be_dict(tmp_path): + write(str(tmp_path / "weird" / "commands.yaml"), "- just\n- a\n- list\n") + out = plugins_store.discover_in(str(tmp_path)) + assert out == {} + + +def test_list_packs_in_reports_both_states(tmp_path): + write(str(tmp_path / "alpha" / "commands.yaml"), GOOD_YAML) + write(str(tmp_path / "bravo" / "commands.yaml"), GOOD_YAML) + write(str(tmp_path / "bravo" / plugins_store.DISABLED_FLAG), "") + listing = plugins_store.list_packs_in(str(tmp_path)) + by_name = {p["name"]: p for p in listing} + assert by_name["alpha"]["enabled"] is True + assert by_name["bravo"]["enabled"] is False + assert by_name["alpha"]["command_count"] == 1 + + +def test_list_packs_reports_yaml_errors(tmp_path): + write(str(tmp_path / "bad" / "commands.yaml"), BAD_YAML) + listing = plugins_store.list_packs_in(str(tmp_path)) + assert len(listing) == 1 + assert listing[0]["error"] is not None + assert listing[0]["command_count"] == 0 diff --git a/tests/test_profiles_store.py b/tests/test_profiles_store.py new file mode 100644 index 0000000..c9815aa --- /dev/null +++ b/tests/test_profiles_store.py @@ -0,0 +1,61 @@ +"""Tests for profiles_store.""" + +import os +import json + + +def test_default_seeded_on_init(isolated_state, tmp_path): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p._init() + names = p.list_names() + # 5 defaults: default, work, game, sleep, driving + for expected in ('default', 'work', 'game', 'sleep', 'driving'): + assert expected in names, f"missing seeded profile: {expected}" + + +def test_active_defaults_to_default(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + a = p.active() + assert a['name'] == 'default' + + +def test_set_active_persists(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p.set_active('work') + assert p.active_name() == 'work' + assert os.path.isfile(p._ACTIVE_FILE) + with open(p._ACTIVE_FILE, encoding='utf-8') as f: + assert f.read().strip() == 'work' + + +def test_set_active_unknown_raises(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + try: + p.set_active('nonexistent') + assert False, "should have raised" + except ValueError: + pass + + +def test_allows_command_default_allows_all(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p.set_active('default') + assert p.allows_command('anything') + assert p.allows_command('games.steam.launch') + + +def test_allows_command_work_disables_games(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p.set_active('work') + assert not p.allows_command('games.steam.launch') + assert not p.allows_command('fun.joke') + assert p.allows_command('time.current') + + +def test_allows_command_driving_whitelist(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p.set_active('driving') + assert p.allows_command('music.play') + assert p.allows_command('weather.now') + assert not p.allows_command('windows.minimize') + assert not p.allows_command('screen.brightness') diff --git a/tests/test_scheduler_store.py b/tests/test_scheduler_store.py new file mode 100644 index 0000000..b59257c --- /dev/null +++ b/tests/test_scheduler_store.py @@ -0,0 +1,109 @@ +"""Tests for scheduler_store: parser + add/remove + serde.""" + +import time +import pytest + + +def test_parse_daily(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("daily 09:00") + assert schedule == {'kind': 'daily', 'hour': 9, 'minute': 0} + + +def test_parse_at_today_or_tomorrow(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("at 23:59") + assert schedule['kind'] == 'once' + assert schedule['at'] > time.time() + + +def test_parse_every_minutes(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("every 30 minutes") + assert schedule == {'kind': 'interval', 'seconds': 1800} + + +def test_parse_every_russian(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("every 2 часа") + assert schedule == {'kind': 'interval', 'seconds': 7200} + + +def test_parse_in_minutes_is_once(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("in 5 minutes") + assert schedule['kind'] == 'once' + # Should be roughly 300 seconds in the future + delta = schedule['at'] - time.time() + assert 290 <= delta <= 310 + + +def test_parse_bad_unit_raises(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + with pytest.raises(ValueError): + s.parse_schedule("every 5 weeks") + + +def test_parse_bad_time_raises(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + with pytest.raises(ValueError): + s.parse_schedule("daily 25:00") + with pytest.raises(ValueError): + s.parse_schedule("at 12:99") + + +def test_add_remove(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + tid = s.add({ + 'name': 'Test', + 'schedule': 'in 1 hour', + 'action': {'type': 'speak', 'text': 'hello'}, + }) + assert tid + tasks = s.list_all() + assert len(tasks) == 1 + assert tasks[0]['name'] == 'Test' + + assert s.remove(tid) is True + assert s.list_all() == [] + assert s.remove(tid) is False # idempotent + + +def test_remove_by_text(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + s.add({ + 'name': 'Water', + 'schedule': 'every 2 hours', + 'action': {'type': 'speak', 'text': 'Попей воды.'}, + }) + s.add({ + 'name': 'Stretch', + 'schedule': 'every 50 minutes', + 'action': {'type': 'speak', 'text': 'Размяться.'}, + }) + + removed = s.remove_by_text("воды") + assert removed == 1 + assert len(s.list_all()) == 1 + + +def test_clear(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + s.add({'name': 'a', 'schedule': 'in 1 hour', 'action': {'type': 'speak', 'text': 'x'}}) + s.add({'name': 'b', 'schedule': 'in 2 hours', 'action': {'type': 'speak', 'text': 'y'}}) + assert s.clear() == 2 + assert s.list_all() == [] + + +def test_next_fire_once(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = {'kind': 'once', 'at': 500} + assert s._next_fire(schedule, None, 1000) == 500 + # Already fired + assert s._next_fire(schedule, 500, 1000) is None + + +def test_next_fire_interval(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = {'kind': 'interval', 'seconds': 60} + assert s._next_fire(schedule, 100, 1000) == 160 diff --git a/tests/test_time_tracker.py b/tests/test_time_tracker.py new file mode 100644 index 0000000..575524c --- /dev/null +++ b/tests/test_time_tracker.py @@ -0,0 +1,179 @@ +"""Tests for time_tracker_handlers — parity with rust time_tracker pack.""" + +import json +import os +import time as _time +import pytest + + +def test_load_returns_empty_when_file_missing(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + data = tt.load() + assert data == {'current_session_start': None, 'sessions': []} + + +def test_start_session_records_timestamp(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + data = tt.load() + assert data['current_session_start'] == 1_700_000_000 + assert data['sessions'] == [] + + +def test_start_session_is_idempotent_when_already_running(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + tt.start_session(now=1_700_010_000) # second start should not overwrite + data = tt.load() + assert data['current_session_start'] == 1_700_000_000 + + +def test_stop_session_pushes_completed(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + _, elapsed = tt.stop_session(now=1_700_003_600) # +1 hour + assert elapsed == 3600 + data = tt.load() + assert data['current_session_start'] is None + assert len(data['sessions']) == 1 + assert data['sessions'][0]['start'] == 1_700_000_000 + assert data['sessions'][0]['end'] == 1_700_003_600 + + +def test_stop_session_without_open_returns_none(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + _, elapsed = tt.stop_session(now=1_700_000_000) + assert elapsed is None + + +def test_today_total_sums_overlap_in_local_day(isolated_state): + """Two sessions today + one yesterday — only today's pair is counted.""" + tt = isolated_state('time_tracker_handlers', ['_PATH']) + + # Pick a deterministic "now" at 15:00 local time today. + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 15 * 3600 # 15:00 local today + + data = { + 'current_session_start': None, + 'sessions': [ + # yesterday: 10:00 → 12:00 (2h, ignored) + {'start': today_midnight - 14 * 3600, 'end': today_midnight - 12 * 3600}, + # today: 09:00 → 10:30 (90 min) + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600 + 1800}, + # today: 13:00 → 14:00 (60 min) + {'start': today_midnight + 13 * 3600, 'end': today_midnight + 14 * 3600}, + ], + } + tt.save(data) + + total = tt.today_total(now=now) + assert total == 90 * 60 + 60 * 60 # 2h30m + + +def test_today_total_includes_open_session(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 10 * 3600 # 10:00 + data = { + 'current_session_start': today_midnight + 9 * 3600, # opened at 09:00 + 'sessions': [], + } + tt.save(data) + total = tt.today_total(now=now) + assert total == 3600 # 1h of open session counts + + +def test_week_total_covers_last_seven_days(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 12 * 3600 + + data = { + 'current_session_start': None, + 'sessions': [ + # 8 days ago: outside window + {'start': today_midnight - 8 * 86400, 'end': today_midnight - 8 * 86400 + 3600}, + # 3 days ago: inside window (1h) + {'start': today_midnight - 3 * 86400, 'end': today_midnight - 3 * 86400 + 3600}, + # today: 2h + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 11 * 3600}, + ], + } + tt.save(data) + + total = tt.week_total(now=now) + assert total == 3 * 3600 # 3 hours + + +def test_reset_today_drops_only_todays_sessions(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 14 * 3600 + data = { + 'current_session_start': today_midnight + 13 * 3600, + 'sessions': [ + {'start': today_midnight - 86400, 'end': today_midnight - 86400 + 3600}, # yesterday + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600}, # today + {'start': today_midnight + 11 * 3600, 'end': today_midnight + 12 * 3600}, # today + ], + } + tt.save(data) + + dropped, had_open = tt.reset_today(now=now) + assert dropped == 2 + assert had_open is True + + after = tt.load() + assert after['current_session_start'] is None + assert len(after['sessions']) == 1 + assert after['sessions'][0]['start'] == today_midnight - 86400 + + +def test_reset_today_when_empty_reports_zero(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + dropped, had_open = tt.reset_today(now=1_700_000_000) + assert dropped == 0 + assert had_open is False + + +def test_format_duration_ru_pluralisation(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + assert tt.format_duration_ru(0) == "0 минут" + assert tt.format_duration_ru(60) == "1 минута" + assert tt.format_duration_ru(2 * 60) == "2 минуты" + assert tt.format_duration_ru(5 * 60) == "5 минут" + assert tt.format_duration_ru(11 * 60) == "11 минут" + assert tt.format_duration_ru(3600) == "1 час" + assert tt.format_duration_ru(2 * 3600) == "2 часа" + assert tt.format_duration_ru(5 * 3600) == "5 часов" + assert tt.format_duration_ru(3 * 3600 + 24 * 60) == "3 часа 24 минуты" + + +def test_persists_atomic_write(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + assert os.path.isfile(tt._PATH) + with open(tt._PATH, encoding='utf-8') as f: + on_disk = json.load(f) + assert on_disk['current_session_start'] == 1_700_000_000 + + +def test_voice_entry_speaks_start_message(isolated_state, monkeypatch): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + spoken = [] + monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s)) + tt.do_tracker_start({}, '') + assert spoken and 'Отсчёт' in spoken[0] + # second call says already running + tt.do_tracker_start({}, '') + assert any('Уже считаю' in s for s in spoken) + + +def test_voice_entry_today_reports_total(isolated_state, monkeypatch): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + spoken = [] + monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s)) + # No data → "ничего не отработано" + tt.do_tracker_today({}, '') + assert spoken and 'ничего не отработано' in spoken[-1].lower() diff --git a/tests/test_wave59_handlers.py b/tests/test_wave59_handlers.py new file mode 100644 index 0000000..655bd81 --- /dev/null +++ b/tests/test_wave59_handlers.py @@ -0,0 +1,152 @@ +"""Tests for `wave59_handlers.py` — Python parity for the Rust-only +wol / banter / conversation / ddg_answer packs. + +Focus is on the testable pure logic — MAC normalisation, magic-packet +construction, trigger stripping, banter state machine. Network calls +(DDG, LLM) are skipped here; they're integration concerns covered by +the Rust pack tests. +""" + +import importlib +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import wave59_handlers + + +def _rewire(isolated_state, *modules): + importlib.reload(wave59_handlers) + out = [] + for name in modules: + mod = isolated_state(name, ['_PATH', '_PROFILES_DIR']) + setattr(wave59_handlers, name, mod) + out.append(mod) + return out + + +def _capture(): + captured = [] + wave59_handlers.set_speak(captured.append) + return captured + + +# ── WOL ───────────────────────────────────────────────────────────────── + + +def test_normalise_mac_accepts_common_formats(): + n = wave59_handlers._normalise_mac + assert n("AA:BB:CC:DD:EE:FF") == "AABBCCDDEEFF" + assert n("aa-bb-cc-dd-ee-ff") == "AABBCCDDEEFF" + assert n("AABB.CCDD.EEFF") == "AABBCCDDEEFF" + assert n("AABBCCDDEEFF") == "AABBCCDDEEFF" + assert n("garbage") is None + assert n("") is None + assert n("AABBCCDD") is None # too short + + +def test_build_magic_packet_shape(): + p = wave59_handlers._build_magic_packet("AABBCCDDEEFF") + # 6 × 0xFF + 16 × MAC = 102 bytes + assert len(p) == 102 + assert p[:6] == b"\xff" * 6 + # First repeat of MAC starts at byte 6 + assert p[6:12] == bytes.fromhex("AABBCCDDEEFF") + # Last repeat at offset 96 + assert p[96:102] == bytes.fromhex("AABBCCDDEEFF") + + +def test_wol_speaks_help_when_mac_not_set(isolated_state, monkeypatch): + (mem,) = _rewire(isolated_state, 'memory_store') + monkeypatch.delenv("JARVIS_WOL_TARGET", raising=False) + spoken = _capture() + wave59_handlers.do_wol() + assert "MAC" in spoken[0] + + +def test_wol_speaks_malformed_when_mac_invalid(isolated_state, monkeypatch): + (mem,) = _rewire(isolated_state, 'memory_store') + monkeypatch.delenv("JARVIS_WOL_TARGET", raising=False) + mem.remember("wol_server", "totally-not-a-mac") + spoken = _capture() + wave59_handlers.do_wol() + assert "подозрительно" in spoken[0] + + +# ── banter ────────────────────────────────────────────────────────────── + + +def test_banter_pause_then_fire_says_quiet(isolated_state): + importlib.reload(wave59_handlers) + spoken = _capture() + wave59_handlers.do_banter_pause() + wave59_handlers.do_banter_fire() + # Two utterances captured: "Молчу..." then a "без комментариев" reply + assert any("Молчу" in s for s in spoken) + assert any("без комментариев" in s.lower() for s in spoken) + + +def test_banter_resume_clears_pause(isolated_state): + importlib.reload(wave59_handlers) + spoken = _capture() + wave59_handlers.do_banter_pause() + wave59_handlers.do_banter_resume() + wave59_handlers.do_banter_fire() + # After resume, fire should return one of the known pool lines. + assert any(s in wave59_handlers._BANTER_LINES for s in spoken) + + +# ── conversation ──────────────────────────────────────────────────────── + + +def test_conversation_repeat_with_no_history(isolated_state): + importlib.reload(wave59_handlers) + wave59_handlers.set_history_ref(None) + spoken = _capture() + wave59_handlers.do_conversation_repeat() + assert "ещё ничего не говорил" in spoken[0] + + +def test_conversation_repeat_speaks_last_assistant(isolated_state): + importlib.reload(wave59_handlers) + history = [ + {"role": "system", "content": "you are jarvis"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "Слушаю, сэр."}, + {"role": "user", "content": "what time"}, + {"role": "assistant", "content": "Восемь утра, сэр."}, + ] + wave59_handlers.set_history_ref(history) + spoken = _capture() + wave59_handlers.do_conversation_repeat() + assert spoken[0] == "Восемь утра, сэр." + + +def test_conversation_summary_empty_history(isolated_state): + importlib.reload(wave59_handlers) + wave59_handlers.set_history_ref([{"role": "system", "content": "x"}]) + spoken = _capture() + wave59_handlers.do_conversation_summary() + assert "пока ещё ни о чём" in spoken[0] + + +# ── DDG trigger stripping ─────────────────────────────────────────────── + + +def test_strip_ddg_trigger_known_prefixes(): + s = wave59_handlers._strip_ddg_trigger + assert s("что такое квантовая запутанность") == "квантовая запутанность" + assert s("кто такой никола тесла") == "никола тесла" + assert s("what is the speed of light") == "the speed of light" + assert s("tell me about jupiter") == "jupiter" + # No trigger → phrase passes through unchanged + assert s("просто текст без триггера") == "просто текст без триггера" + assert s("") == "" + + +def test_ddg_answer_with_empty_query_says_so(isolated_state): + importlib.reload(wave59_handlers) + spoken = _capture() + wave59_handlers.do_ddg_answer(voice="что такое") + assert "Что искать" in spoken[0] diff --git a/tests/test_wave68_handlers.py b/tests/test_wave68_handlers.py new file mode 100644 index 0000000..0952861 --- /dev/null +++ b/tests/test_wave68_handlers.py @@ -0,0 +1,236 @@ +"""Tests for `wave68_handlers.py` — Python parity for Wave 6-8 Rust packs. + +Each test captures speech via `set_speak(callback)` so we can assert on what +J.A.R.V.I.S. would have said. Scheduler / memory state lives in tmp_path via +the `isolated_state` fixture from conftest.py. + +Implementation gotcha: `isolated_state` re-imports `memory_store` / +`scheduler_store` etc with `_PATH` pointing at the per-test tmpdir, but the +already-imported `wave68_handlers` keeps a reference to the OLD modules. +`_rewire(...)` below re-binds those refs so handlers hit the tmp stores. +""" + +import importlib +import os +import sys + +# Make project root importable. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import wave68_handlers + + +def _rewire(isolated_state, *modules): + """Re-import each named state module via `isolated_state`, then rebind + the wave68_handlers reference so the handler picks up the tmp_path + instance. Returns the list of (re-imported) modules. + """ + importlib.reload(wave68_handlers) + out = [] + for name in modules: + mod = isolated_state(name, ['_PATH', '_PROFILES_DIR']) + setattr(wave68_handlers, name, mod) + out.append(mod) + return out + + +def _capture_speak(): + captured = [] + wave68_handlers.set_speak(captured.append) + return captured + + +# ── memory admin ───────────────────────────────────────────────────────── + + +def test_memory_count_uses_russian_plural(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_count() + assert "0 фактов" in spoken[0] + + mem.remember('любимый чай', 'улун') + spoken.clear() + wave68_handlers.do_memory_count() + assert "1 факт" in spoken[0] + assert "факта" not in spoken[0] # singular, not "2 факта" + + mem.remember('питомец', 'собака') + mem.remember('город', 'москва') + spoken.clear() + wave68_handlers.do_memory_count() + assert "3 факта" in spoken[0] # 2-4 → "факта" + + for i in range(2): + mem.remember(f'k{i}', f'v{i}') + spoken.clear() + wave68_handlers.do_memory_count() + assert "5 фактов" in spoken[0] # 5+ → "фактов" + + +def test_memory_list_speaks_empty_state(isolated_state): + _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_list() + assert "пуст" in spoken[0].lower() + + +def test_memory_list_shows_recent_first(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + # Inject pre-aged record directly so timestamps are distinct without + # waiting on the clock — `remember` uses wall-clock seconds and two + # back-to-back calls in tests routinely tie. + mem._load() + mem._store['старый'] = { + 'key': 'старый', 'value': 'факт', + 'created_at': 100, 'last_used_at': 100, 'use_count': 0, + } + mem._store['новый'] = { + 'key': 'новый', 'value': 'факт', + 'created_at': 200, 'last_used_at': 200, 'use_count': 0, + } + spoken = _capture_speak() + wave68_handlers.do_memory_list() + text = spoken[0] + # Both must appear, with новый (last_used_at=200) before старый (=100) + assert 'новый' in text + assert 'старый' in text + assert text.index('новый') < text.index('старый') + + +def test_memory_wipe_returns_count(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + mem.remember('a', '1') + mem.remember('b', '2') + mem.remember('c', '3') + spoken = _capture_speak() + wave68_handlers.do_memory_wipe() + assert "3" in spoken[0] + assert len(mem.all_facts()) == 0 + + +def test_memory_wipe_on_empty_is_polite(isolated_state): + _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_wipe() + assert "пуста" in spoken[0] + + +# ── cooking ───────────────────────────────────────────────────────────── + + +def test_match_recipe_finds_substrings(): + # Direct unit test on the matcher — no scheduler side effects. + assert wave68_handlers.match_recipe("варю макароны") == (10, "макароны") + assert wave68_handlers.match_recipe("ставлю чайник") == (4, "чайник") + assert wave68_handlers.match_recipe("жарю омлет") == (5, "омлет") + assert wave68_handlers.match_recipe("какая сегодня погода") is None + assert wave68_handlers.match_recipe("") is None + + +def test_cooking_schedules_a_task(isolated_state): + (sched,) = _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_cooking(voice="варю пасту 200 грамм") + tasks = sched.list_all() + assert len(tasks) == 1 + assert "паст" in tasks[0]['name'].lower() + assert "10" in spoken[0] + + +def test_cooking_unmatched_phrase_says_so(isolated_state): + _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_cooking(voice="какая погода в москве") + assert "не понял" in spoken[0].lower() + + +# ── leftoff ───────────────────────────────────────────────────────────── + + +def test_leftoff_pulls_memory_and_tasks(isolated_state): + mem, sched = _rewire(isolated_state, 'memory_store', 'scheduler_store') + mem.remember('текущий проект', 'jarvis рефакторинг') + sched.add({ + "name": "Daily standup", + "schedule": sched.parse_schedule("daily 09:00"), + "action": {"type": "speak", "text": "standup"}, + }) + spoken = _capture_speak() + wave68_handlers.do_leftoff(last_assistant_reply="Готовлю кофе, сэр.") + text = spoken[0] + assert "текущий проект" in text + assert "Daily standup" in text + assert "Готовлю кофе" in text + + +def test_leftoff_empty_is_polite(isolated_state): + _rewire(isolated_state, 'memory_store', 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_leftoff() + assert "ничего" in spoken[0].lower() + + +# ── trivia ────────────────────────────────────────────────────────────── + + +def test_trivia_returns_one_known_line(): + spoken = _capture_speak() + wave68_handlers.do_trivia(lang="ru") + assert spoken[0] in wave68_handlers._TRIVIA_RU + + +def test_trivia_english_pool(): + spoken = _capture_speak() + wave68_handlers.do_trivia(lang="en") + assert spoken[0] in wave68_handlers._TRIVIA_EN + + +# ── routines ──────────────────────────────────────────────────────────── + + +def test_good_night_cancels_one_shot_timers(isolated_state): + sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store') + # Pre-populate: one one-shot ("Таймер:") and one daily (should stay) + sched.add({ + "name": "Таймер: яичница", + "schedule": sched.parse_schedule("in 5 minutes"), + "action": {"type": "speak", "text": "ready"}, + }) + sched.add({ + "name": "Daily briefing", + "schedule": sched.parse_schedule("daily 09:00"), + "action": {"type": "speak", "text": "morning"}, + }) + spoken = _capture_speak() + wave68_handlers.do_good_night() + remaining = [t['name'] for t in sched.list_all()] + assert "Daily briefing" in remaining + assert not any("Таймер:" in n for n in remaining) + assert "1" in spoken[0] # cancelled-count appears in message + + +def test_good_morning_reports_clear_or_busy_schedule(isolated_state): + sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store') + spoken = _capture_speak() + wave68_handlers.do_good_morning() + assert "чистое" in spoken[0].lower() or "clear" in spoken[0].lower() + + sched.add({ + "name": "Task", + "schedule": sched.parse_schedule("daily 10:00"), + "action": {"type": "speak", "text": "x"}, + }) + spoken.clear() + wave68_handlers.do_good_morning() + assert "1" in spoken[0] + + +def test_coffee_break_creates_5min_checkin(isolated_state): + (sched,) = _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_coffee_break() + tasks = sched.list_all() + assert len(tasks) == 1 + assert "Чек-ин" in tasks[0]['name'] + assert spoken[0] # non-empty diff --git a/time_tracker_handlers.py b/time_tracker_handlers.py new file mode 100644 index 0000000..b0fb7d2 --- /dev/null +++ b/time_tracker_handlers.py @@ -0,0 +1,263 @@ +"""Time tracker — voice-driven daily work-time log. + +Parity with the rust `time_tracker` command pack. Stores active session state +and a list of completed sessions in `/time_tracker.json` with +atomic write-through (write-then-rename). + +Public API: + do_tracker_start(action, voice) -> None + do_tracker_stop(action, voice) -> None + do_tracker_today(action, voice) -> None + do_tracker_week(action, voice) -> None + do_tracker_reset(action, voice) -> None + +Lower-level helpers (also used by tests): + load() -> dict + save(data) -> None + start_session(now=None) -> dict # returns updated state + stop_session(now=None) -> tuple[dict, int|None] # (state, elapsed seconds) + today_total(now=None) -> int # total seconds today (incl. open session) + week_total(now=None) -> int # total seconds across last 7 days + reset_today(now=None) -> tuple[int, bool] # (dropped_count, had_open) +""" + +import json +import os +import time as _time + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'time_tracker.json') + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[tracker] speak: {exc}") + + +# ── persistence ────────────────────────────────────────────────────────── + +def load() -> dict: + if not os.path.isfile(_PATH): + return {'current_session_start': None, 'sessions': []} + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print(f"[tracker] corrupt store ({exc}) — starting empty") + return {'current_session_start': None, 'sessions': []} + if not isinstance(data, dict): + return {'current_session_start': None, 'sessions': []} + data.setdefault('current_session_start', None) + data.setdefault('sessions', []) + return data + + +def save(data: dict) -> None: + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as exc: + print(f"[tracker] save failed: {exc}") + + +# ── local-day math ─────────────────────────────────────────────────────── + +def _local_today_start(now: int) -> int: + """Return the unix timestamp of local midnight on the day that contains + `now`.""" + tm = _time.localtime(now) + return now - (tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec) + + +# ── operations ─────────────────────────────────────────────────────────── + +def start_session(now: int | None = None) -> dict: + """Open a new active session if none is running. Returns the new state.""" + if now is None: + now = int(_time.time()) + data = load() + if data.get('current_session_start') is None: + data['current_session_start'] = now + save(data) + return data + + +def stop_session(now: int | None = None) -> tuple[dict, int | None]: + """Close the open session (if any). Returns (state, elapsed_seconds).""" + if now is None: + now = int(_time.time()) + data = load() + start_ts = data.get('current_session_start') + if start_ts is None: + return data, None + elapsed = max(1, now - int(start_ts)) + sessions = data.get('sessions') or [] + sessions.append({'start': int(start_ts), 'end': now}) + data['sessions'] = sessions + data['current_session_start'] = None + save(data) + return data, elapsed + + +def _sum_overlap(sessions: list[dict], open_start: int | None, + lo: int, hi: int) -> int: + total = 0 + for sess in sessions: + s = int(sess.get('start') or 0) + e = int(sess.get('end') or 0) + a = max(s, lo) + b = min(e, hi) + if b > a: + total += b - a + if open_start is not None: + a = max(int(open_start), lo) + if hi > a: + total += hi - a + return total + + +def today_total(now: int | None = None) -> int: + if now is None: + now = int(_time.time()) + data = load() + today_start = _local_today_start(now) + return _sum_overlap(data.get('sessions') or [], + data.get('current_session_start'), + today_start, now) + + +def week_total(now: int | None = None) -> int: + if now is None: + now = int(_time.time()) + data = load() + today_start = _local_today_start(now) + week_start = today_start - 6 * 86400 + return _sum_overlap(data.get('sessions') or [], + data.get('current_session_start'), + week_start, now) + + +def reset_today(now: int | None = None) -> tuple[int, bool]: + """Drop today's completed sessions and stop any open one. + Returns (dropped_count, had_open_session).""" + if now is None: + now = int(_time.time()) + data = load() + today_start = _local_today_start(now) + sessions = data.get('sessions') or [] + kept = [s for s in sessions if int(s.get('start') or 0) < today_start] + dropped = len(sessions) - len(kept) + had_open = data.get('current_session_start') is not None + data['sessions'] = kept + data['current_session_start'] = None + save(data) + return dropped, had_open + + +# ── formatting ─────────────────────────────────────────────────────────── + +def _ru_hour_word(n: int) -> str: + n100 = n % 100 + if 11 <= n100 <= 14: + return 'часов' + n10 = n % 10 + if n10 == 1: + return 'час' + if 2 <= n10 <= 4: + return 'часа' + return 'часов' + + +def _ru_minute_word(n: int) -> str: + n100 = n % 100 + if 11 <= n100 <= 14: + return 'минут' + n10 = n % 10 + if n10 == 1: + return 'минута' + if 2 <= n10 <= 4: + return 'минуты' + return 'минут' + + +def _ru_session_word(n: int) -> str: + n100 = n % 100 + if 11 <= n100 <= 14: + return 'сессий' + n10 = n % 10 + if n10 == 1: + return 'сессия' + if 2 <= n10 <= 4: + return 'сессии' + return 'сессий' + + +def format_duration_ru(seconds: int) -> str: + seconds = max(0, int(seconds)) + h = seconds // 3600 + m = (seconds % 3600) // 60 + if h > 0 and m > 0: + return f"{h} {_ru_hour_word(h)} {m} {_ru_minute_word(m)}" + if h > 0: + return f"{h} {_ru_hour_word(h)}" + return f"{m} {_ru_minute_word(m)}" + + +# ── voice entry points ─────────────────────────────────────────────────── + +def do_tracker_start(action, voice): + data = load() + if data.get('current_session_start') is not None: + _speak("Уже считаю, сэр.") + return + start_session() + _speak("Отсчёт пошёл.") + + +def do_tracker_stop(action, voice): + _, elapsed = stop_session() + if elapsed is None: + _speak("Трекер не запущен, сэр.") + return + _speak(f"Записал. Сессия: {format_duration_ru(elapsed)}.") + + +def do_tracker_today(action, voice): + total = today_total() + if total < 60: + _speak("Сегодня ещё ничего не отработано, сэр.") + return + _speak(f"Сегодня отработано {format_duration_ru(total)}.") + + +def do_tracker_week(action, voice): + total = week_total() + if total < 60: + _speak("За эту неделю ничего не отработано, сэр.") + return + _speak(f"За неделю отработано {format_duration_ru(total)}.") + + +def do_tracker_reset(action, voice): + dropped, had_open = reset_today() + if dropped == 0 and not had_open: + _speak("Сегодня сбрасывать нечего, сэр.") + return + parts = ["Подтверждаю: трекер за сегодня очищен"] + if dropped > 0: + parts.append(f", удалено {dropped} {_ru_session_word(dropped)}") + if had_open: + parts.append(", активный отсчёт остановлен") + parts.append(".") + _speak("".join(parts)) diff --git a/tts.py b/tts.py index 9930298..35ca6f9 100644 --- a/tts.py +++ b/tts.py @@ -1,3 +1,14 @@ +"""TTS — Silero ru_v3 voice synth with lazy initialisation. + +Why lazy: torch.hub.load() blocks for 30-120 seconds on the first run +(downloading the model) and produces no progress output beyond a single +"Using cache found in..." line. If this runs at MODULE IMPORT time, the +whole jarvis-python startup looks frozen — exactly the bug the user hit. + +Fix: defer the load until first synth call. main.py prints its startup +banner first, then this module loads on demand. Side effect: the first +"speak" is slow (3-4s), but every print before it is visible. +""" import time import numpy as np @@ -14,13 +25,31 @@ speaker = 'aidar' # aidar, baya, kseniya, xenia, random put_accent = True put_yo = True device = torch.device('cpu') # cpu или gpu -text = "Хауди Хо, друзья!!!" -model, _ = torch.hub.load(repo_or_dir='snakers4/silero-models', +# Initialised on first synthesize() call. +_model = None + + +def _ensure_model(): + """Load Silero on first use. Subsequent calls are no-ops. + + Prints a one-line progress hint so the user can tell the assistant is + busy downloading rather than hung — critical for the first run when + the model isn't cached yet. + """ + global _model + if _model is not None: + return _model + print("[tts] Loading Silero ru_v3 (first use — ~30s if model isn't cached)...") + started = time.time() + m, _ = torch.hub.load(repo_or_dir='snakers4/silero-models', model='silero_tts', language=language, speaker=model_id) -model.to(device) + m.to(device) + _model = m + print(f"[tts] Silero ready ({time.time() - started:.1f}s).") + return _model def _post_process(audio): @@ -45,11 +74,12 @@ def _post_process(audio): def synthesize(what: str): - audio = model.apply_tts(text=what + "..", - speaker=speaker, - sample_rate=sample_rate, - put_accent=put_accent, - put_yo=put_yo) + m = _ensure_model() + audio = m.apply_tts(text=what + "..", + speaker=speaker, + sample_rate=sample_rate, + put_accent=put_accent, + put_yo=put_yo) return _post_process(audio) diff --git a/vision_handler.py b/vision_handler.py new file mode 100644 index 0000000..e39f307 --- /dev/null +++ b/vision_handler.py @@ -0,0 +1,152 @@ +"""Vision handler — port of `crates/jarvis-core/src/lua/api/vision.rs`. + +Captures the primary screen via PowerShell System.Drawing, base64-encodes +the PNG, sends to Groq's vision-capable model. Requires GROQ_TOKEN. + +Action types in commands.yaml: + vision_describe "что на экране" / "опиши экран" + vision_read_error "прочитай ошибку" +""" + +import base64 +import os +import subprocess +import tempfile + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[vision] speak: {exc}") + + +_SCREENSHOT_PS_TEMPLATE = """ +Add-Type -AssemblyName System.Windows.Forms; +Add-Type -AssemblyName System.Drawing; +$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; +$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height; +$g = [System.Drawing.Graphics]::FromImage($bmp); +$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size); +$bmp.Save('{path}', [System.Drawing.Imaging.ImageFormat]::Png); +$g.Dispose(); $bmp.Dispose(); +""" + + +def _take_screenshot() -> str | None: + """Save full screen to a temp PNG, return path or None on failure.""" + fd, path = tempfile.mkstemp(prefix='jarvis-screen-', suffix='.png') + os.close(fd) + ps = _SCREENSHOT_PS_TEMPLATE.format(path=path.replace("'", "''")) + try: + subprocess.run( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps], + capture_output=True, timeout=10, check=True, + ) + except Exception as exc: + print(f"[vision] screenshot: {exc}") + try: + os.unlink(path) + except OSError: + pass + return None + if not os.path.isfile(path) or os.path.getsize(path) < 1000: + try: + os.unlink(path) + except OSError: + pass + return None + return path + + +def _vision_call(prompt: str, image_b64: str) -> str | None: + # Vision is Groq-specific (Ollama doesn't expose vision via OpenAI-compat + # endpoint in our stack). Use config.GROQ_TOKEN directly regardless of the + # active text-LLM backend. + try: + import config as cfg + except ImportError: + return None + if not getattr(cfg, 'GROQ_TOKEN', None): + return None + + try: + from openai import OpenAI + except ImportError: + return None + + client = OpenAI( + api_key=cfg.GROQ_TOKEN, + base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), + ) + + model = os.environ.get('GROQ_VISION_MODEL', 'llama-3.2-11b-vision-preview') + + try: + resp = client.chat.completions.create( + model=model, + messages=[{ + 'role': 'user', + 'content': [ + {'type': 'text', 'text': prompt}, + {'type': 'image_url', 'image_url': { + 'url': f'data:image/png;base64,{image_b64}', + }}, + ], + }], + max_tokens=400, temperature=0.2, timeout=30, + ) + return resp.choices[0].message.content.strip() + except Exception as exc: + print(f"[vision] LLM call: {exc}") + return None + + +def _describe(prompt: str) -> str | None: + """Take a screenshot and ask vision-LLM. Returns description or None.""" + path = _take_screenshot() + if not path: + return None + try: + with open(path, 'rb') as f: + b64 = base64.b64encode(f.read()).decode('ascii') + finally: + try: + os.unlink(path) + except OSError: + pass + return _vision_call(prompt, b64) + + +def do_vision_describe(action, voice): + _speak("Сейчас посмотрю.") + prompt = ( + "Опиши коротко (1-3 предложения) что сейчас на экране. По-русски." + ) + result = _describe(prompt) + if not result: + _speak("Не удалось разобрать экран. Проверь GROQ_TOKEN.") + return + _speak(result) + + +def do_vision_read_error(action, voice): + _speak("Смотрю на ошибку.") + prompt = ( + "Найди на экране текст ошибки (сообщение об ошибке, stack trace, диалог " + "об исключении). Прочитай ключевое сообщение и одним коротким " + "предложением подскажи возможную причину. Если ошибки нет — скажи " + "'ошибок не вижу'. Отвечай по-русски. 1-3 предложения максимум." + ) + result = _describe(prompt) + if not result: + _speak("Не получилось прочитать.") + return + _speak(result) diff --git a/wave1_handlers.py b/wave1_handlers.py new file mode 100644 index 0000000..9c56af2 --- /dev/null +++ b/wave1_handlers.py @@ -0,0 +1,613 @@ +"""Wave 1 handler module — port of 10 rust packs from c4b2261. + +magic_8ball, github_issues, weather_extended, rss_reader, ics_event, +backup, clip_history, notif_queue, password_vault, habit_streaks. + +Imported by extensions.py + dispatched from main.py. +""" + +import base64 +import datetime as dt +import json as _json +import os +import random +import re +import subprocess +import urllib.parse +import urllib.request +import zipfile + +import memory_store + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[wave1] speak: {exc}") + + +def _ps(cmd, timeout=15): + try: + res = subprocess.run( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cmd], + capture_output=True, text=True, encoding='utf-8', timeout=timeout, + ) + return res.returncode == 0, (res.stdout or '').strip(), (res.stderr or '') + except Exception as exc: + return False, '', str(exc) + + +def _gh(*args, timeout=15): + try: + res = subprocess.run( + ['gh', *args], + capture_output=True, text=True, encoding='utf-8', timeout=timeout, + ) + return res.returncode == 0, res.stdout, res.stderr + except Exception as exc: + return False, '', str(exc) + + +def _http_json(url, timeout=10): + try: + with urllib.request.urlopen(url, timeout=timeout) as r: + return _json.loads(r.read().decode()) + except Exception as exc: + print(f"[wave1] http: {exc}") + return None + + +# ── magic_8ball ──────────────────────────────────────────────────────────── + +_8BALL = [ + "Без сомнения, сэр.", "Однозначно да.", "Можете на это рассчитывать.", + "Скорее всего да.", "Перспективы хорошие.", "Знаки указывают на да.", + "Пока неясно, попробуйте позже.", "Сосредоточьтесь и спросите снова.", + "Не могу предсказать сейчас.", "Лучше не говорить.", + "Не рассчитывайте на это.", "Мой ответ — нет.", "Источники говорят нет.", + "Перспективы не очень.", "Очень сомнительно.", +] + + +def do_magic_8ball(action, voice): + _speak(random.choice(_8BALL)) + + +# ── github_issues ───────────────────────────────────────────────────────── + +def do_github_list_issues(action, voice): + repo = memory_store.recall("github.repo") + if not repo: + _speak("Сначала укажите репозиторий: текущий репо owner/repo.") + return + ok, out, _ = _gh('issue', 'list', '--repo', repo, '--state', 'open', + '--json', 'number,title,labels', '--limit', '10') + if not ok: + _speak("gh CLI не отвечает.") + return + try: + items = _json.loads(out or '[]') + except _json.JSONDecodeError: + items = [] + if not items: + _speak("Открытых issues нет.") + return + titles = [i.get('title', '') for i in items[:3] if i.get('title')] + line = f"{len(items)} открытых issues. " + if titles: + line += "Первые: " + ". ".join(titles) + "." + _speak(line) + + +def do_github_my_issues(action, voice): + ok, out, _ = _gh('issue', 'list', '--assignee', '@me', '--state', 'open', + '--json', 'number,title,repository', '--limit', '10') + if not ok: + _speak("gh CLI не отвечает.") + return + try: + items = _json.loads(out or '[]') + except _json.JSONDecodeError: + items = [] + if not items: + _speak("Тебе ничего не назначено.") + return + titles = [i.get('title', '') for i in items[:3] if i.get('title')] + line = f"На вас {len(items)} issues. " + if titles: + line += "Первые: " + ". ".join(titles) + "." + _speak(line) + + +# ── weather_extended ────────────────────────────────────────────────────── + +def _weather_location(): + lat = memory_store.recall("weather.lat") + lon = memory_store.recall("weather.lon") + city = memory_store.recall("weather.city") + if lat and lon: + return lat, lon, city or "вашем городе" + ip = _http_json("https://ipinfo.io/json", timeout=5) + if not ip or not ip.get('loc'): + return None, None, None + parts = ip['loc'].split(',') + if len(parts) != 2: + return None, None, None + lat, lon = parts[0], parts[1] + city = ip.get('city', 'ваш город') + memory_store.remember("weather.lat", lat) + memory_store.remember("weather.lon", lon) + memory_store.remember("weather.city", city) + return lat, lon, city + + +def _weather_code_ru(code): + if code == 0: return "ясно" + if code <= 3: return "переменная облачность" + if 51 <= code <= 67: return "дождь" + if 71 <= code <= 77: return "снег" + if code >= 95: return "гроза" + return "переменно" + + +def do_weather_tomorrow(action, voice): + lat, lon, city = _weather_location() + if not lat: + _speak("Не получилось узнать местоположение.") + return + url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code" + f"&timezone=auto&forecast_days=2") + data = _http_json(url, timeout=10) + if not data or 'daily' not in data: + _speak("Прогноз недоступен.") + return + d = data['daily'] + if len(d.get('temperature_2m_max', [])) < 2: + _speak("Прогноз неполный.") + return + tmax = d['temperature_2m_max'][1] + tmin = d['temperature_2m_min'][1] + rain = d.get('precipitation_sum', [0, 0])[1] or 0 + code = d.get('weather_code', [0, 0])[1] or 0 + line = f"В {city} завтра {_weather_code_ru(code)}. От {round(tmin)} до {round(tmax)} градусов." + if rain > 5: + line += " Возможны осадки." + _speak(line) + + +def do_weather_week(action, voice): + lat, lon, city = _weather_location() + if not lat: + _speak("Не получилось узнать местоположение.") + return + url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"&daily=temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7") + data = _http_json(url, timeout=10) + if not data or 'daily' not in data: + _speak("Прогноз недоступен.") + return + maxes = data['daily'].get('temperature_2m_max', []) + mins = data['daily'].get('temperature_2m_min', []) + if not maxes: + _speak("Прогноз неполный.") + return + wk_max = max(maxes) + wk_min = min(mins) + _speak(f"Неделя в {city}: от {round(wk_min)} до {round(wk_max)} градусов.") + + +# ── rss_reader ──────────────────────────────────────────────────────────── + +def do_rss_add(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in ('добавь rss', 'подпишись на', 'запомни ленту', 'добавь ленту'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + m = re.search(r'(https?://\S+)', body) + if not m: + _speak("Не нашёл URL.") + return + url = m.group(1) + domain = url.split('://', 1)[1].split('/')[0] + memory_store.remember(f"rss.{domain}", url) + _speak(f"Лента {domain} добавлена.") + + +def do_rss_read(action, voice): + feed_url = None + feed_name = None + for rec in memory_store.all_facts(): + if rec['key'].startswith('rss.'): + feed_url = rec['value'] + feed_name = rec['key'][4:] + break + if not feed_url: + _speak("Лент не добавлено. Скажите 'добавь rss и URL'.") + return + try: + with urllib.request.urlopen(feed_url, timeout=10) as r: + body = r.read().decode('utf-8', errors='replace') + except Exception as exc: + print(f"[wave1] rss fetch: {exc}") + _speak("Не получилось загрузить ленту.") + return + titles = re.findall(r']*>([^<]+)', body) + if len(titles) < 2: + _speak("В ленте нет заголовков.") + return + sample = titles[1:4] # skip channel title + _speak(f"Из {feed_name}. " + ". ".join(sample) + ".") + + +def do_rss_list(action, voice): + feeds = [r['key'][4:] for r in memory_store.all_facts() if r['key'].startswith('rss.')] + if not feeds: + _speak("Лент пока нет.") + return + _speak("Подписан на: " + ", ".join(feeds) + ".") + + +# ── ics_event ───────────────────────────────────────────────────────────── + +def do_ics_create(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in ('добавь встречу', 'создай событие', 'создай встречу', + 'запиши встречу', 'новая встреча'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + if not body: + _speak("Опишите встречу.") + return + + # Find time HH:MM or "в HH" + m = re.search(r'(\d{1,2})[:\-\s](\d{2})', body) + if m: + hh, mm = int(m.group(1)), int(m.group(2)) + else: + m = re.search(r'в\s+(\d{1,2})', body) + hh = int(m.group(1)) if m else 9 + mm = 0 + hh = max(0, min(23, hh)) + mm = max(0, min(59, mm)) + + today = dt.date.today() + low2 = body.lower() + if 'сегодня' in low2: + target = today + elif 'послезавтра' in low2: + target = today + dt.timedelta(days=2) + else: + target = today + dt.timedelta(days=1) + + summary = re.sub(r'\d{1,2}[:\-\s]\d{2}', '', body) + summary = re.sub(r'в\s+\d{1,2}', '', summary) + summary = re.sub(r'(сегодня|послезавтра|завтра)', '', summary, flags=re.IGNORECASE) + summary = summary.strip(' ,.:') or "Встреча" + + now = dt.datetime.now() + now_stamp = now.strftime('%Y%m%dT%H%M%S') + dt_start = f"{target.strftime('%Y%m%d')}T{hh:02d}{mm:02d}00" + end_hh = (hh + 1) % 24 + dt_end = f"{target.strftime('%Y%m%d')}T{end_hh:02d}{mm:02d}00" + uid = f"jarvis-{now_stamp}-{random.randint(1000, 9999)}" + + ics = ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//J.A.R.V.I.S.//EN\r\n" + "BEGIN:VEVENT\r\n" + f"UID:{uid}\r\nDTSTAMP:{now_stamp}\r\n" + f"DTSTART:{dt_start}\r\nDTEND:{dt_end}\r\n" + f"SUMMARY:{summary}\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ) + + userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public') + out_dir = os.path.join(userprofile, 'Documents', 'jarvis-events') + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"event-{now_stamp}.ics") + try: + with open(out_path, 'w', encoding='utf-8') as f: + f.write(ics) + except OSError as exc: + print(f"[wave1] ics write: {exc}") + _speak("Не получилось создать файл.") + return + + # Open via default handler. + os.startfile(out_path) + _speak(f"Встреча создана: {summary} в {hh:02d}:{mm:02d}.") + + +# ── backup ──────────────────────────────────────────────────────────────── + +def do_backup_export(action, voice): + cfg_dir = memory_store._PATH and os.path.dirname(memory_store._PATH) or \ + os.path.dirname(os.path.abspath(__file__)) + appdata = os.environ.get('APPDATA', '') + candidates = [] + # Search in script-relative + APPDATA/Jarvis + for base in (cfg_dir, os.path.join(appdata, 'Jarvis') if appdata else None): + if not base or not os.path.isdir(base): + continue + for fname in ('long_term_memory.json', 'schedule.json', 'macros.json', + 'active_profile.txt', 'llm_backend.txt', 'settings.json'): + p = os.path.join(base, fname) + if os.path.isfile(p): + candidates.append(p) + prof_dir = os.path.join(base, 'profiles') + if os.path.isdir(prof_dir): + for f in os.listdir(prof_dir): + p = os.path.join(prof_dir, f) + if os.path.isfile(p): + candidates.append(p) + if not candidates: + _speak("Нечего бекапить.") + return + + userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public') + stamp = dt.datetime.now().strftime('%Y%m%d-%H%M%S') + out_zip = os.path.join(userprofile, 'Documents', f'jarvis-backup-{stamp}.zip') + try: + with zipfile.ZipFile(out_zip, 'w', zipfile.ZIP_DEFLATED) as zf: + for src in candidates: + zf.write(src, os.path.basename(src)) + except OSError as exc: + print(f"[wave1] backup: {exc}") + _speak("Не получилось создать бекап.") + return + _speak(f"Бекап сохранён, {len(candidates)} файлов.") + + +# ── clip_history ────────────────────────────────────────────────────────── + +def _get_clipboard(): + """Read current clipboard via PowerShell.""" + ok, out, _ = _ps("Get-Clipboard -Raw", timeout=5) + return out if ok else '' + + +def _set_clipboard(text): + """Write to clipboard via PowerShell. None/empty → clears.""" + try: + subprocess.run( + ['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input'], + input=(text or ''), text=True, encoding='utf-8', timeout=10, check=False, + ) + except Exception as exc: + print(f"[wave1] clipboard set: {exc}") + + +def do_clip_save(action, voice): + content = _get_clipboard() + if not content: + _speak("Буфер пуст.") + return + # Shift + for i in range(19, 0, -1): + prev = memory_store.recall(f"clip.{i - 1}") + if prev: + memory_store.remember(f"clip.{i}", prev) + trimmed = content if len(content) <= 2000 else content[:2000] + "..." + memory_store.remember("clip.0", trimmed) + preview = trimmed[:40].replace('\n', ' ').replace('\r', ' ') + _speak(f"Запомнил: {preview}.") + + +def do_clip_list(action, voice): + count = 0 + previews = [] + for i in range(20): + val = memory_store.recall(f"clip.{i}") + if val: + count += 1 + if len(previews) < 3: + p = val[:30].replace('\n', ' ').replace('\r', ' ') + previews.append(p) + if count == 0: + _speak("История буфера пуста.") + return + _speak("В истории буфера: " + " | ".join(previews) + f". Всего {count} записей.") + + +def do_clip_restore(action, voice): + low = (voice or '').lower() + idx = 0 + if 'втор' in low: idx = 1 + elif 'трет' in low: idx = 2 + elif 'четверт' in low: idx = 3 + elif 'пят' in low: idx = 4 + m = re.search(r'(\d+)', low) + if m: + idx = int(m.group(1)) - 1 + content = memory_store.recall(f"clip.{idx}") + if not content: + _speak("В истории нет такой записи.") + return + _set_clipboard(content) + preview = content[:30].replace('\n', ' ') + _speak(f"Восстановил: {preview}.") + + +# ── notif_queue ─────────────────────────────────────────────────────────── + +def do_notif_missed(action, voice): + notifs = [] + for rec in memory_store.all_facts(): + if rec['key'].startswith('notif.'): + notifs.append((rec.get('last_used_at', 0), rec['value'])) + if not notifs: + _speak("Ничего не пропустил.") + return + notifs.sort(reverse=True) + sample = notifs[:5] + _speak(f"Пропущено {len(notifs)} уведомлений. " + ". ".join(n[1] for n in sample) + ".") + + +def do_notif_clear(action, voice): + count = 0 + for rec in memory_store.all_facts(): + if rec['key'].startswith('notif.'): + memory_store.forget(rec['key']) + count += 1 + _speak("Уведомлений и так нет." if count == 0 else f"Очистил {count} уведомлений.") + + +# ── password_vault ──────────────────────────────────────────────────────── + +def _dpapi_encrypt(plaintext): + escaped = plaintext.replace("'", "''") + ps = ( + "Add-Type -AssemblyName System.Security;" + f"$bytes = [System.Text.Encoding]::UTF8.GetBytes('{escaped}');" + "$enc = [System.Security.Cryptography.ProtectedData]::Protect(" + "$bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);" + "Write-Output ([Convert]::ToBase64String($enc))" + ) + ok, out, _ = _ps(ps, timeout=10) + return out if ok else None + + +def _dpapi_decrypt(b64): + ps = ( + "Add-Type -AssemblyName System.Security;" + f"$bytes = [Convert]::FromBase64String('{b64}');" + "try {" + " $dec = [System.Security.Cryptography.ProtectedData]::Unprotect(" + " $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);" + " [System.Text.Encoding]::UTF8.GetString($dec) | Set-Clipboard;" + " Write-Output 'OK' } catch { Write-Output 'ERR' }" + ) + ok, out, _ = _ps(ps, timeout=10) + return ok and out == 'OK' + + +def _vault_strip_trigger(voice): + low = (voice or '').lower() + name = voice or '' + for trig in ('покажи пароль от', 'достань пароль от', 'пароль от', + 'сохрани пароль от', 'запомни пароль от', 'дай пароль', + 'пароль для', 'password for', 'get password', + 'save password for', 'remember password for'): + idx = low.find(trig) + if idx >= 0: + # find same trigger in original-case voice + name = name[idx + len(trig):].strip(' ,.:') + break + return name.strip() + + +def do_vault_save(action, voice): + name = _vault_strip_trigger(voice) + if not name: + _speak("Имя сервиса не указано.") + return + plaintext = _get_clipboard() + if not plaintext: + _speak("Скопируйте пароль в буфер сначала.") + return + b64 = _dpapi_encrypt(plaintext) + if not b64: + _speak("Не получилось зашифровать.") + return + memory_store.remember(f"vault.{name}", b64) + _set_clipboard("") + _speak(f"Сохранил пароль от {name}.") + + +def do_vault_get(action, voice): + name = _vault_strip_trigger(voice) + if not name: + _speak("От чего пароль?") + return + b64 = memory_store.recall(f"vault.{name}") + if not b64: + _speak(f"Пароля для {name} нет.") + return + if not _dpapi_decrypt(b64): + _speak("Не получилось расшифровать.") + return + # Clear clipboard 30 seconds later via background thread. + import threading + def _clear(): + import time + time.sleep(30) + _set_clipboard("") + threading.Thread(target=_clear, daemon=True).start() + _speak(f"Пароль от {name} в буфере на 30 секунд.") + + +def do_vault_list(action, voice): + names = [r['key'][6:] for r in memory_store.all_facts() if r['key'].startswith('vault.')] + if not names: + _speak("В хранилище паролей пусто.") + return + _speak(f"Паролей сохранено {len(names)}: " + ", ".join(names) + ".") + + +# ── habit_streaks ───────────────────────────────────────────────────────── + +def _habit_from_voice(voice): + low = (voice or '').lower() + if 'вод' in low: return 'water' + if 'размял' in low or 'зарядк' in low: return 'stretch' + if 'глаз' in low: return 'eyes' + # Strip leading trigger and use first word + for trig in ('отметь привычку', 'выполнил привычку', 'выполнил', 'отметь', + 'check in habit'): + idx = low.find(trig) + if idx >= 0: + body = (voice or '')[idx + len(trig):].strip(' ,.:').strip() + return body or 'general' + return 'general' + + +def do_habit_checkin(action, voice): + habit = _habit_from_voice(voice) + today = dt.date.today().strftime('%Y-%m-%d') + memory_store.remember(f"habit_streak.{habit}.{today}", "1") + _speak(f"Отметил {habit} за сегодня.") + + +def do_habit_streak(action, voice): + by_habit = {} + for rec in memory_store.all_facts(): + m = re.match(r'^habit_streak\.([^.]+)\.(.+)$', rec['key']) + if m: + habit, date = m.group(1), m.group(2) + by_habit.setdefault(habit, set()).add(date) + if not by_habit: + _speak("Привычек ещё не отмечено. Скажи 'выполнил привычку' после события.") + return + today = dt.date.today() + results = [] + for habit, dates in by_habit.items(): + streak = 0 + for d in range(31): + day = (today - dt.timedelta(days=d)).strftime('%Y-%m-%d') + if day in dates: + streak += 1 + else: + break + results.append((habit, streak)) + results.sort(key=lambda x: -x[1]) + sample = results[:3] + lines = [] + for habit, streak in sample: + if streak == 0: + lines.append(f"{habit} прервана") + else: + unit = 'день' if streak == 1 else ('дня' if streak < 5 else 'дней') + lines.append(f"{habit} {streak} {unit}") + _speak("Стрики: " + ", ".join(lines) + ".") diff --git a/wave59_handlers.py b/wave59_handlers.py new file mode 100644 index 0000000..0b06cdf --- /dev/null +++ b/wave59_handlers.py @@ -0,0 +1,289 @@ +"""Python parity for Wave 5-9 voice packs that were Rust-only: + + - wol Wake-on-LAN magic packet to a MAC stored in memory + - banter pause / resume idle banter, fire-one + - conversation summary + repeat from LLM history + - ddg_answer DuckDuckGo Instant Answer Q&A (no API key) + +These piggyback on existing Python modules (memory_store, scheduler_store, +the openai client kept in main.py) and degrade gracefully — e.g. ddg_answer +opens a browser fallback when the API has no instant answer. + +Each `do_*` is callable with the standard `(action, voice)` signature so +extensions.py can wire it identically to the Rust packs' yaml entries. +""" +import json +import random +import re +import socket +import struct +import urllib.parse +import urllib.request +import webbrowser +from typing import Callable, Optional, List + +import memory_store + + +_speak: Optional[Callable[[str], None]] = None +_history: Optional[List[dict]] = None # populated by main.py at startup + + +def set_speak(fn: Callable[[str], None]) -> None: + global _speak + _speak = fn + + +def set_history_ref(history_list: list) -> None: + """main.py owns the live LLM `message_log`. Inject the reference here so + conversation.summary/repeat can read it without re-importing main.""" + global _history + _history = history_list + + +def _say(text: str) -> None: + if _speak is not None: + _speak(text) + else: + print(f"[wave59] {text}") + + +# ─── Wake-on-LAN ──────────────────────────────────────────────────────────── + + +def _normalise_mac(mac: str) -> Optional[str]: + """Return the canonical 12-hex-digits form or None if malformed. + + Accepts AA:BB:CC:DD:EE:FF, AA-BB-..., AABB.CCDD.EEFF, AABBCCDDEEFF. + """ + cleaned = re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper() + if len(cleaned) != 12: + return None + return cleaned + + +def _build_magic_packet(mac_hex: str) -> bytes: + mac_bytes = bytes.fromhex(mac_hex) + return b"\xff" * 6 + mac_bytes * 16 + + +def _send_magic_packet(packet: bytes) -> bool: + """Best-effort UDP broadcast on port 9. Returns True on send success.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.sendto(packet, ("255.255.255.255", 9)) + return True + except OSError as e: + print(f"[wol] send failed: {e}") + return False + + +def do_wol(action=None, voice=None, alias: str = "server") -> None: + """Mirror of resources/commands/wol/wake.lua. + + Reads MAC from memory["wol_"] or env JARVIS_WOL_TARGET, normalises, + and fires the magic packet. The alias is fixed to "server" here just like + the Rust side — extending to multiple aliases is straightforward.""" + mac = memory_store.recall(f"wol_{alias}") + if not mac: + import os + mac = os.environ.get("JARVIS_WOL_TARGET", "") + if not mac: + _say(f"Сэр, MAC-адрес не задан. Скажи: запомни wol_{alias}, а потом адрес.") + return + cleaned = _normalise_mac(mac) + if cleaned is None: + _say(f"Сэр, MAC-адрес в памяти выглядит подозрительно: {mac}") + return + ok = _send_magic_packet(_build_magic_packet(cleaned)) + if not ok: + _say("Не получилось отправить пакет.") + return + pretty = "-".join(cleaned[i:i+2] for i in range(0, 12, 2)) + _say(f"Пакет отправлен на {pretty}") + + +# ─── Idle banter ──────────────────────────────────────────────────────────── +# +# Python doesn't ship a background idle-banter thread (the Rust one does). +# These handlers expose just the user-facing actions so the same voice +# phrases work on both sides. A real Python bg-thread would mirror +# crates/jarvis-core/src/idle_banter.rs — left as future work, since the +# Python edition is typically run alongside the Rust daemon anyway. + +_BANTER_PAUSED = False + +_BANTER_LINES = [ + "Системы в норме, сэр. Всё под контролем.", + "Я здесь, если что, сэр. Никуда не делся.", + "Сэр, мне иногда кажется, что я думаю. Возможно, мне это только кажется.", + "Сэр, статус: чашка кофе крайне рекомендована.", + "К вашим услугам, сэр. Голос звучит немного хрипло, но это исправимо.", + "Жду команд, сэр. У меня есть весь день.", +] + + +def do_banter_fire(action=None, voice=None) -> None: + if _BANTER_PAUSED: + _say("В этот раз без комментариев, сэр.") + return + _say(random.choice(_BANTER_LINES)) + + +def do_banter_pause(action=None, voice=None) -> None: + global _BANTER_PAUSED + _BANTER_PAUSED = True + _say("Молчу, сэр.") + + +def do_banter_resume(action=None, voice=None) -> None: + global _BANTER_PAUSED + _BANTER_PAUSED = False + _say("Хорошо, сэр. Возвращаюсь к комментариям.") + + +# ─── Conversation summary / repeat ────────────────────────────────────────── + + +def do_conversation_summary(action=None, voice=None) -> None: + """LLM-driven recap of the last ~12 turns from main.py's message_log. + + Offline-safe: if GROQ_TOKEN missing or history empty, returns a graceful + short message instead of attempting a network call. + """ + if _history is None or len(_history) <= 1: + # _history[0] is the system prompt; need at least one real turn + _say("Мы пока ещё ни о чём не говорили, сэр.") + return + + # last 12 real turns (skip system at [0]) + turns = _history[-12:] if len(_history) > 12 else _history[1:] + if not turns: + _say("Мы пока ещё ни о чём не говорили, сэр.") + return + + import os + token = os.environ.get("GROQ_TOKEN", "") + if not token: + # Offline fallback — speak the last user message + last_user = next( + (m for m in reversed(turns) if m.get("role") == "user"), + None, + ) + if last_user: + _say("Без сети, сэр. Последний раз вы спросили: " + last_user.get("content", "")[:200]) + else: + _say("Без сети, сэр.") + return + + try: + from openai import OpenAI + client = OpenAI( + api_key=token, + base_url=os.environ.get("GROQ_BASE_URL", "https://api.groq.com/openai/v1"), + ) + transcript_lines = [] + for m in turns: + who = "Я" if m.get("role") == "assistant" else "Пользователь" + transcript_lines.append(who + ": " + (m.get("content") or "")) + sys_msg = ( + "Ты — J.A.R.V.I.S. По ниже идущему диалогу с пользователем составь короткое " + "напоминание (1-3 предложения), о чём шла речь. Говори от первого лица в " + "стиле британского дворецкого. Не пересказывай дословно — сожми суть." + ) + resp = client.chat.completions.create( + model=os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile"), + messages=[ + {"role": "system", "content": sys_msg}, + {"role": "user", "content": "\n".join(transcript_lines)}, + ], + max_tokens=200, + temperature=0.4, + ) + text = (resp.choices[0].message.content or "").strip() + if not text: + _say("Не смог разобрать ответ.") + return + _say(text) + except Exception as e: + print(f"[conversation] LLM call failed: {e}") + _say("LLM не отвечает.") + + +def do_conversation_repeat(action=None, voice=None) -> None: + if _history is None: + _say("Я ещё ничего не говорил, сэр.") + return + last = next( + (m for m in reversed(_history) if m.get("role") == "assistant"), + None, + ) + if last is None: + _say("Я ещё ничего не говорил, сэр.") + return + _say(last.get("content") or "") + + +# ─── DuckDuckGo Instant Answer ────────────────────────────────────────────── + + +_DDG_TRIGGERS = [ + "что такое", "кто такой", "кто такая", + "расскажи про", "расскажи о", + "найди информацию о", "найди информацию про", + "поищи в интернете", "поищи в сети", + "погугли что такое", + "what is", "who is", "tell me about", "look up", "search the web for", +] + + +def _strip_ddg_trigger(phrase: str) -> str: + p = (phrase or "").lower().strip() + for t in _DDG_TRIGGERS: + if p.startswith(t): + return p[len(t):].strip() + return p + + +def do_ddg_answer(action=None, voice=None) -> None: + query = _strip_ddg_trigger(voice or "") + if not query: + _say("Что искать?") + return + url = ("https://api.duckduckgo.com/?q=" + + urllib.parse.quote(query, safe="") + + "&format=json&no_html=1&skip_disambig=1") + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 J.A.R.V.I.S."}) + with urllib.request.urlopen(req, timeout=8) as resp: + data = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError) as e: + print(f"[ddg] fetch failed: {e}") + _say("DDG не отвечает.") + return + + answer = (data.get("AbstractText") or "").strip() + if not answer: + answer = (data.get("Answer") or "").strip() + if not answer: + answer = (data.get("Definition") or "").strip() + if not answer: + related = data.get("RelatedTopics") or [] + for r in related: + t = (r or {}).get("Text", "").strip() + if t: + answer = t + break + if not answer: + fallback = "https://duckduckgo.com/?q=" + urllib.parse.quote(query, safe="") + try: + webbrowser.open(fallback) + except OSError: + pass + _say(f"Ничего конкретного не нашёл, открываю поиск по запросу: {query}") + return + + if len(answer) > 320: + answer = answer[:300] + "…" + _say(answer) diff --git a/wave68_handlers.py b/wave68_handlers.py new file mode 100644 index 0000000..0c66436 --- /dev/null +++ b/wave68_handlers.py @@ -0,0 +1,278 @@ +"""Python parity for Wave 6-8 voice packs that were Rust-only at first. + +Covers: + - memory_admin: count / list / wipe + - cooking: dish-name → preset-duration timer via scheduler + - leftoff: cross-state recap (no LLM) + - trivia: random MCU one-liner + - routines: good_night / good_morning / coffee_break + +Style: each `do_*` returns nothing; speech goes through the `set_speak`- +injected callback, mirroring how the other handlers (wave1_handlers, +personality_handlers, ...) work. Keeps the handler module testable in +isolation since the speak fn is a thin dependency. +""" + +import random +import time +from typing import Callable, Optional + +import memory_store +import scheduler_store +import profiles_store + + +_speak: Optional[Callable[[str], None]] = None + + +def set_speak(fn: Callable[[str], None]) -> None: + """Wired by extensions.py at startup; lets main.py route TTS through whichever + backend the user has active (Silero / Piper / SAPI).""" + global _speak + _speak = fn + + +def _say(text: str) -> None: + if _speak is not None: + _speak(text) + else: + # Tests don't always wire a speak fn — fall back to print so the test + # can still assert on the captured content via capsys/caplog. + print(f"[wave68] {text}") + + +# ─── Memory admin ────────────────────────────────────────────────────────── + + +def _ru_plural_facts(n: int) -> str: + """Russian declension: 1 факт, 2-4 факта, 5+ фактов, 11-14 exception.""" + m100 = n % 100 + if 11 <= m100 <= 14: + return "фактов" + m10 = n % 10 + if m10 == 1: + return "факт" + if 2 <= m10 <= 4: + return "факта" + return "фактов" + + +def do_memory_count(action=None, voice=None) -> None: + n = len(memory_store.all_facts()) + _say(f"В памяти {n} {_ru_plural_facts(n)}, сэр.") + + +def do_memory_list(action=None, voice=None) -> None: + facts = memory_store.all_facts() + if not facts: + _say("Память пуста, сэр.") + return + # Sort by recency + facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True) + preview = facts[:5] + rest = max(0, len(facts) - len(preview)) + parts = [f"{f['key']} — {(f.get('value') or '')[:60]}" for f in preview] + msg = ", ".join(parts) + if rest > 0: + msg += f". И ещё {rest}." + _say(msg) + + +def do_memory_wipe(action=None, voice=None) -> None: + n = memory_store.clear_all() + if n == 0: + _say("Память уже пуста, сэр.") + else: + _say(f"Стёр {n} записей. Память чиста.") + + +# ─── Cooking timer ───────────────────────────────────────────────────────── + +# (lowercase substring, minutes, ru-label, en-label) +_RECIPES = [ + ("чайник", 4, "чайник", "kettle"), + ("французск", 4, "френч-пресс", "french press"), + ("чай", 5, "чай", "tea"), + ("омлет", 5, "омлет", "omelette"), + ("яичниц", 5, "яичница", "fried eggs"), + ("кофе", 5, "кофе", "coffee"), + ("яйц", 8, "яйца", "eggs"), + ("макарон", 10, "макароны", "pasta"), + ("паст", 10, "паста", "pasta"), + ("пицц", 15, "пицца", "pizza"), + ("рис", 18, "рис", "rice"), + ("греч", 20, "гречка", "buckwheat"), + ("каш", 20, "каша", "porridge"), + ("картош", 22, "картошка", "potatoes"), + ("куриц", 35, "курица", "chicken"), +] + + +def match_recipe(phrase: str): + """Return (minutes, ru_label) for the first matching recipe substring, + or None if nothing in the phrase matches. Exposed for tests.""" + p = (phrase or "").lower() + for sub, mins, ru, _en in _RECIPES: + if sub in p: + return mins, ru + return None + + +def do_cooking(action=None, voice=None) -> None: + """Detect dish in the voice phrase, schedule a 'ready' reminder for the + right number of minutes through the persistent scheduler. + + Signature matches the rest of the `do_*` handlers: (action_dict, voice). + The recipe match looks at `voice` (the raw phrase the user said).""" + phrase = voice or "" + match = match_recipe(phrase) + if match is None: + _say("Не понял, что ты готовишь, сэр.") + return + minutes, label = match + spec = f"in {minutes} minutes" + try: + sched = scheduler_store.parse_schedule(spec) + except ValueError as e: + _say(f"Не получилось распарсить расписание: {e}") + return + task = { + "name": f"Кухня: {label}", + "schedule": sched, + "action": {"type": "speak", "text": f"Сэр, {label} готов."}, + } + scheduler_store.add(task) + _say(f"Хорошо, напомню через {minutes} минут когда {label} будет готов.") + + +# ─── Leftoff recap ──────────────────────────────────────────────────────── + + +def do_leftoff(action=None, voice=None, last_assistant_reply: Optional[str] = None) -> None: + """Stitch a recap from independent sources. Pure-offline. + + `last_assistant_reply` is callable from tests to pin the third source; + the production dispatcher passes None (main.py keeps its own LLM history).""" + parts = [] + + facts = memory_store.all_facts() + if facts: + facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True)[:3] + items = [f"{f['key']} — {(f.get('value') or '')[:50]}" for f in facts] + parts.append("Из памяти: " + "; ".join(items) + ".") + + tasks = scheduler_store.list_all() + if tasks: + first_name = tasks[0].get("name") or "task" + parts.append(f"В расписании {len(tasks)}, ближайшее: {first_name}.") + + if last_assistant_reply: + snippet = " ".join(last_assistant_reply.split())[:120] + parts.append("Я говорил: " + snippet) + + if not parts: + _say("Ничего недавно не происходило, сэр.") + return + _say(" ".join(parts)) + + +# ─── Trivia ─────────────────────────────────────────────────────────────── + +_TRIVIA_RU = [ + "Первый костюм Старк собрал в пещере в Афганистане. Десять рабочих недель, ноль вентиляции.", + "Дуговый реактор изначально питал электромагнит, удерживающий осколки шрапнели у самого сердца.", + "Палладий в первом реакторе медленно отравлял Старка. Решением стал новый, синтезированный отцом, элемент.", + "Mark I весил около 200 килограммов и летал лишь однажды — на побег из плена.", + "Mark III выкрашен в красно-золотой потому, что чистое золото оказалось мягче, чем хотелось.", + "Mark V — складной костюм в чемодане. Идеальное оружие на званый ужин.", + "Hulkbuster, Mark XLIV, был запущен совместно с Брюсом Беннером — на случай, если Халк выйдет из берегов.", + "Я, сэр, появился в фильме «Железный человек» 2008 года. Меня озвучил Пол Беттани.", + "Имя J.A.R.V.I.S. — отсылка к Эдвину Джарвису, дворецкому отца Тони, Говарда Старка.", + "После событий «Эры Альтрона» программа J.A.R.V.I.S. была интегрирована в Vision.", +] + +_TRIVIA_EN = [ + "Stark built the first suit in a cave in Afghanistan. Ten weeks, no ventilation.", + "The arc reactor originally powered an electromagnet keeping shrapnel away from his heart.", + "Palladium in the first reactor was slowly poisoning him. The cure was a new element his father had synthesised.", + "Mark I weighed roughly 200 kilograms and flew exactly once — for the escape.", + "Mark III is red-and-gold because pure gold turned out to be too soft.", + "Mark V folds into a briefcase. Useful at galas.", + "I, sir, first appeared in Iron Man (2008). Paul Bettany voiced me.", + "The name J.A.R.V.I.S. nods to Edwin Jarvis, butler to Tony's father Howard.", +] + + +def do_trivia(action=None, voice=None, lang: str = "ru") -> None: + pool = _TRIVIA_EN if lang == "en" else _TRIVIA_RU + _say(random.choice(pool)) + + +# ─── Routines ───────────────────────────────────────────────────────────── + + +def do_good_night(action=None, voice=None) -> None: + """Switch to 'sleep' profile, cancel one-shot timers, speak goodnight.""" + try: + profiles_store.set_active("sleep") + except (ValueError, KeyError): + pass + + cancelled = 0 + for task in scheduler_store.list_all(): + name = task.get("name") or "" + if name.startswith(("Таймер:", "Timer:", "Кухня:", "Cooking:")): + if scheduler_store.remove(task.get("id", "")): + cancelled += 1 + + lines = [ + "Спокойной ночи, сэр. Я остаюсь на связи.", + "Доброй ночи, сэр. Системы переведены в тихий режим.", + "Спите крепко, сэр. Утром доложу о всём важном.", + "Хорошо, сэр. Не отвлекаю до утра.", + ] + line = random.choice(lines) + if cancelled > 0: + line += f" Отменил {cancelled} одноразовых таймеров." + _say(line) + + +def do_good_morning(action=None, voice=None) -> None: + try: + profiles_store.set_active("default") + except (ValueError, KeyError): + pass + n_tasks = len(scheduler_store.list_all()) + lines = [ + "Доброе утро, сэр. Системы в порядке.", + "Доброе утро. Готов к новому дню.", + "С пробуждением, сэр. День в норме.", + "Доброе утро, сэр. Кофе сам себя не сварит.", + ] + line = random.choice(lines) + if n_tasks > 0: + line += f" В расписании {n_tasks}, скажите 'покажи расписание', чтобы услышать." + else: + line += " Расписание чистое." + _say(line) + + +def do_coffee_break(action=None, voice=None) -> None: + """Schedule a 5-min check-in reminder. Banter pause is Rust-only — Python + side doesn't have an idle-banter equivalent yet.""" + try: + sched = scheduler_store.parse_schedule("in 5 minutes") + except ValueError: + _say("Не получилось поставить чек-ин.") + return + scheduler_store.add({ + "name": "Чек-ин после кофе", + "schedule": sched, + "action": {"type": "speak", "text": "Сэр, прошло пять минут. Вы вернулись?"}, + }) + lines = [ + "Хорошо, сэр. Возвращайтесь через пять.", + "Тихо в эфире пять минут. Удачно вам.", + "Пять минут — норма для кофе, не более.", + ] + _say(random.choice(lines))