diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index b258562..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -# 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 a1f13f1..992e6b7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,17 +8,6 @@ __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 417165b..12abbd5 100644 --- a/README.md +++ b/README.md @@ -37,35 +37,6 @@ 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 68aac0e..a80d0e3 100644 --- a/commands.yaml +++ b/commands.yaml @@ -20,99 +20,82 @@ open_google: - запусти гугл action: {type: url, href: "https://www.google.com", browser: "yandex"} -open_notepad: +music: phrases: - - открой блокнот - - запусти блокнот - - открой нотепад - action: {type: shell, cmd: 'start "" notepad.exe'} + - давай музыку + - музыка + - яндекс музыка + - включи музыку + - открой музыку + - послушаем музыку + - хочу музыку + - врубай музло + - включи что-то послушать + - давай что-то послушаем + action: {type: exe, file: "Run music.exe"} -open_calculator: +music_off: phrases: - - открой калькулятор - - запусти калькулятор - - нужен калькулятор - action: {type: shell, cmd: 'start "" calc.exe'} + - выключи музыку + - отруби музыку + - убери музыку + - отключи музыку + - закрой музыку + - останови музыку + - хватит музыки + action: {type: exe, file: "Stop music.exe", delay_ms: 200} -open_explorer: +music_save: phrases: - - открой проводник - - открой папку - - запусти проводник - - открой файлы - action: {type: shell, cmd: 'start "" explorer.exe'} + - сохрани трек + - мне нравится трек + - сохрани песню + - мне нравится песня + - добавь трек в избранное + - добавь песню в избранное + - запомни мелодию + - запомни трек + - запомни песню + - добавь мелодию в избранное + - сохрани то что сейчас играет + - добавь то что сейчас играет в избранное + - лайкни трек + - лайкни мелодию + - лайкни песню + action: {type: exe, file: "Save music.exe", delay_ms: 200} -open_terminal: +music_next: phrases: - - открой терминал - - запусти терминал - - открой консоль - - открой повершелл - action: {type: shell, cmd: 'start "" wt.exe || start "" powershell.exe'} + - следующий трек + - следующая мелодия + - следующая песня + - переключи трек + - переключи музыку + - переключи мелодию + - переключи песню + - следующая музыка + action: {type: exe, file: "Next music.exe", delay_ms: 200} -open_settings: +music_prev: phrases: - - открой настройки - - открой параметры - - открой настройки виндоус - 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'} + - предыдущая музыка + - предыдущий трек + - предыдущая мелодия + - предыдущая песня + - верни трек + - верни мелодию + - верни песню + - верни прошлый трек + - верни прошлую мелодию + - верни прошлую песню + - верни прошлую музыку + - верни то что играло + - давай предыдущий трек + - переключи музыку обратно + - переключи на прошлый трек + - переключи на прошлую мелодию + - переключи на прошлую песню + action: {type: exe, file: "Prev music.exe", delay_ms: 200} sound_off: phrases: @@ -137,686 +120,6 @@ 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: - спасибо @@ -838,6 +141,85 @@ 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: - выключись @@ -855,947 +237,3 @@ stupid: 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 e7af1c9..b78f82c 100644 --- a/config.py +++ b/config.py @@ -31,12 +31,6 @@ 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 deleted file mode 100644 index 9318698..0000000 --- a/dev_handlers.py +++ /dev/null @@ -1,313 +0,0 @@ -"""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 deleted file mode 100644 index 81612e9..0000000 --- a/expenses_handlers.py +++ /dev/null @@ -1,177 +0,0 @@ -"""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 deleted file mode 100644 index 948e248..0000000 --- a/extensions.py +++ /dev/null @@ -1,1191 +0,0 @@ -"""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 deleted file mode 100644 index 6c8f127..0000000 --- a/gui.bat +++ /dev/null @@ -1,5 +0,0 @@ -@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 deleted file mode 100644 index 9c17412..0000000 --- a/gui/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""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 deleted file mode 100644 index ff07ddf..0000000 --- a/gui/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""`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 deleted file mode 100644 index afe2f4b..0000000 --- a/gui/app.py +++ /dev/null @@ -1,81 +0,0 @@ -"""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 deleted file mode 100644 index 2302ae1..0000000 --- a/gui/pages/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Page modules: each exposes `build(page: flet.Page) -> flet.Control`.""" diff --git a/gui/pages/commands.py b/gui/pages/commands.py deleted file mode 100644 index b8a622f..0000000 --- a/gui/pages/commands.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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 deleted file mode 100644 index a59d59c..0000000 --- a/gui/pages/home.py +++ /dev/null @@ -1,119 +0,0 @@ -"""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 deleted file mode 100644 index f89267f..0000000 --- a/gui/pages/macros.py +++ /dev/null @@ -1,90 +0,0 @@ -"""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 deleted file mode 100644 index fb8f549..0000000 --- a/gui/pages/memory.py +++ /dev/null @@ -1,74 +0,0 @@ -"""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 deleted file mode 100644 index 233020c..0000000 --- a/gui/pages/plugins.py +++ /dev/null @@ -1,97 +0,0 @@ -"""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 deleted file mode 100644 index d46f05f..0000000 --- a/gui/pages/scheduler.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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 deleted file mode 100644 index b3db0bd..0000000 --- a/gui/pages/settings.py +++ /dev/null @@ -1,77 +0,0 @@ -"""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 deleted file mode 100644 index 7d76728..0000000 --- a/gui/services.py +++ /dev/null @@ -1,159 +0,0 @@ -"""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 deleted file mode 100644 index 51bf468..0000000 --- a/llm_backend.py +++ /dev/null @@ -1,152 +0,0 @@ -"""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 deleted file mode 100644 index 05566a2..0000000 --- a/macros_store.py +++ /dev/null @@ -1,179 +0,0 @@ -"""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 ec824fa..dbb75a7 100644 --- a/main.py +++ b/main.py @@ -10,68 +10,13 @@ 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 @@ -91,18 +36,6 @@ 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). " @@ -112,15 +45,6 @@ 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") @@ -215,24 +139,14 @@ def play(phrase, wait_done=True): if wait_done: recorder.stop() - # 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 + wave_obj = sa.WaveObject.from_wave_file(filename) + play_obj = wave_obj.play() 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() @@ -254,12 +168,10 @@ 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(): - auto_ok = ( - config.LLM_AUTO_FALLBACK - and config.GROQ_TOKEN - and len(voice.strip()) >= config.LLM_AUTO_FALLBACK_MIN_CHARS - ) - if auto_ok: + # play("not_found") + # tts.va_speak("Что?") + if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75: + message_log.append({"role": "user", "content": voice}) response = gpt_answer() message_log.append({"role": "assistant", "content": response}) @@ -312,1202 +224,6 @@ 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': @@ -1553,297 +269,14 @@ 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'] - _current_voice = voice - try: - run_action(action) - finally: - _current_voice = '' + run_action(action) confirm = spec.get('confirm_sound') if confirm is None and action['type'] == 'exe': confirm = 'ok' @@ -1851,44 +284,6 @@ 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 deleted file mode 100644 index 78dc0ee..0000000 --- a/memory_store.py +++ /dev/null @@ -1,136 +0,0 @@ -"""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 deleted file mode 100644 index 093046d..0000000 --- a/outlook_handlers.py +++ /dev/null @@ -1,308 +0,0 @@ -"""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 deleted file mode 100644 index 79477dc..0000000 --- a/personality_handlers.py +++ /dev/null @@ -1,192 +0,0 @@ -"""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 deleted file mode 100644 index 6a34ad3..0000000 --- a/plugins_store.py +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index a7123f3..0000000 --- a/profiles_store.py +++ /dev/null @@ -1,168 +0,0 @@ -"""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 17c4804..780ab1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,10 +27,6 @@ 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 @@ -42,8 +38,3 @@ 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 deleted file mode 100644 index ebe45fb..0000000 --- a/run.bat +++ /dev/null @@ -1,13 +0,0 @@ -@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 deleted file mode 100644 index 51deb8c..0000000 --- a/scheduler_store.py +++ /dev/null @@ -1,307 +0,0 @@ -"""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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ce595a0..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,45 +0,0 @@ -"""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 deleted file mode 100644 index f752724..0000000 --- a/tests/test_expenses_handlers.py +++ /dev/null @@ -1,116 +0,0 @@ -"""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 deleted file mode 100644 index 909420c..0000000 --- a/tests/test_gui_smoke.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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 deleted file mode 100644 index f98cc25..0000000 --- a/tests/test_llm_backend.py +++ /dev/null @@ -1,68 +0,0 @@ -"""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 deleted file mode 100644 index ff53efc..0000000 --- a/tests/test_macros_store.py +++ /dev/null @@ -1,87 +0,0 @@ -"""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 deleted file mode 100644 index 7ed055f..0000000 --- a/tests/test_memory_store.py +++ /dev/null @@ -1,79 +0,0 @@ -"""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 deleted file mode 100644 index 7c149e6..0000000 --- a/tests/test_outlook_handlers.py +++ /dev/null @@ -1,103 +0,0 @@ -"""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 deleted file mode 100644 index 7fdb82a..0000000 --- a/tests/test_personality_handlers.py +++ /dev/null @@ -1,62 +0,0 @@ -"""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 deleted file mode 100644 index 03495f5..0000000 --- a/tests/test_plugins_store.py +++ /dev/null @@ -1,96 +0,0 @@ -"""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 deleted file mode 100644 index c9815aa..0000000 --- a/tests/test_profiles_store.py +++ /dev/null @@ -1,61 +0,0 @@ -"""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 deleted file mode 100644 index b59257c..0000000 --- a/tests/test_scheduler_store.py +++ /dev/null @@ -1,109 +0,0 @@ -"""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 deleted file mode 100644 index 575524c..0000000 --- a/tests/test_time_tracker.py +++ /dev/null @@ -1,179 +0,0 @@ -"""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 deleted file mode 100644 index 655bd81..0000000 --- a/tests/test_wave59_handlers.py +++ /dev/null @@ -1,152 +0,0 @@ -"""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 deleted file mode 100644 index 0952861..0000000 --- a/tests/test_wave68_handlers.py +++ /dev/null @@ -1,236 +0,0 @@ -"""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 deleted file mode 100644 index b0fb7d2..0000000 --- a/time_tracker_handlers.py +++ /dev/null @@ -1,263 +0,0 @@ -"""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 35ca6f9..9930298 100644 --- a/tts.py +++ b/tts.py @@ -1,14 +1,3 @@ -"""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 @@ -25,31 +14,13 @@ speaker = 'aidar' # aidar, baya, kseniya, xenia, random put_accent = True put_yo = True device = torch.device('cpu') # cpu или gpu +text = "Хауди Хо, друзья!!!" -# 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, _ = torch.hub.load(repo_or_dir='snakers4/silero-models', model='silero_tts', language=language, speaker=model_id) - m.to(device) - _model = m - print(f"[tts] Silero ready ({time.time() - started:.1f}s).") - return _model +model.to(device) def _post_process(audio): @@ -74,12 +45,11 @@ def _post_process(audio): def synthesize(what: str): - m = _ensure_model() - audio = m.apply_tts(text=what + "..", - speaker=speaker, - sample_rate=sample_rate, - put_accent=put_accent, - put_yo=put_yo) + audio = model.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 deleted file mode 100644 index e39f307..0000000 --- a/vision_handler.py +++ /dev/null @@ -1,152 +0,0 @@ -"""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 deleted file mode 100644 index 9c56af2..0000000 --- a/wave1_handlers.py +++ /dev/null @@ -1,613 +0,0 @@ -"""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 deleted file mode 100644 index 0b06cdf..0000000 --- a/wave59_handlers.py +++ /dev/null @@ -1,289 +0,0 @@ -"""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 deleted file mode 100644 index 0c66436..0000000 --- a/wave68_handlers.py +++ /dev/null @@ -1,278 +0,0 @@ -"""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))