From 80a685230cc799ba3d6e8a095a6c972168cb506a Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 01:48:34 +0300 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20parity=20with=20rust=20fork=20?= =?UTF-8?q?=E2=80=94=20LLM=20auto-fallback=20+=206=20new=20action=20types?= =?UTF-8?q?=20+=2020+=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the python fork in line with the new feature/pc-tools work that landed in rust. Same UX, same trigger phrases, same env vars. config.py: + LLM_AUTO_FALLBACK = True + LLM_AUTO_FALLBACK_MIN_CHARS = 4 Mirrors the rust config knobs. The old "скажи X" trigger still works (VA_TBR strips it before intent matching, so what remains hits this fallback anyway) — but now ANY unrecognized query goes to Groq, not just "скажи" ones. main.py: va_respond: replaced the fuzz-on-"скажи" gate with a unified auto- fallback that fires whenever no command matches, GROQ_TOKEN is set, and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long. + _speak(text): recorder.stop() → tts.va_speak → start, used by the new action handlers so the mic doesn't pick up the assistant. + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no new python deps. + _strip_trigger(voice, triggers): shared helper for codegen/file_search where we need the text AFTER the trigger phrase. New action handlers (registered in run_action dispatch): + groq_codegen — extracts query, calls Groq with strict "code only" system prompt at temp 0.2, strips ```lang ... ``` fences, puts the result in the clipboard, speaks "Код в буфере, сэр. Строк: N". + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG, shells to tesseract.exe (-l rus+eng), speaks a 120-char preview, clipboard. Looks for tesseract on PATH first then standard install dirs; gives a clear "Tesseract не установлен" if missing. + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time via python datetime (gets us localized weekday for free). Topics: time / battery / cpu / ram / disk / all. + file_search — strips trigger, recursive Get-ChildItem in Desktop/ Documents/Downloads (depth 3, top 5), opens explorer /select on the first hit, speaks count and basename. + audio_devices_panel — opens mmsys.cpl. Proper COM-based device switching (like the rust IPolicyConfig version) is deferred — TODO in backlog. commands.yaml: Total 33 commands (was 19). New: + open_notepad / open_calculator / open_explorer / open_terminal / open_settings / open_task_manager / lock_screen / screenshot — all via shell action (no AHK exes needed). + volume_up / volume_down — five chained keys=volumeup/volumedown presses each, like rust's _volume_helper.ps1 with Times=5. + media_stop — keys=stop. + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram / sysinfo_disk / sysinfo_all — sysinfo action. + file_search, codegen, read_screen, switch_audio_device. Replaced (were AHK exes that Priler ships and we don't have): ~ music → keys playpause ~ music_off → keys playpause (toggle) ~ music_next → keys nexttrack ~ music_prev → keys prevtrack Removed (Priler hardware/setup specific, can't sensibly port): - gaming_mode_on / gaming_mode_off (display config switch) - music_save (depends on the specific player AHK was driving) - switch_to_headphones / switch_to_dynamics (replaced with the generic switch_audio_device that opens mmsys.cpl) Kept as-is (worked already, no AHK dependency): open_browser / open_youtube / open_google (url action) sound_off / sound_on (pycaw via system.volume_mute/unmute) thanks / stupid / off Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK. Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk, PATH lookup for tesseract before falling back to standard install dirs. No hardcoded user-specific paths. --- commands.yaml | 370 ++++++++++++++++++++++++++++++-------------------- config.py | 6 + main.py | 238 +++++++++++++++++++++++++++++++- 3 files changed, 463 insertions(+), 151 deletions(-) diff --git a/commands.yaml b/commands.yaml index a80d0e3..e7c91fc 100644 --- a/commands.yaml +++ b/commands.yaml @@ -20,82 +20,99 @@ open_google: - запусти гугл action: {type: url, href: "https://www.google.com", browser: "yandex"} -music: +open_notepad: phrases: - - давай музыку - - музыка - - яндекс музыка - - включи музыку - - открой музыку - - послушаем музыку - - хочу музыку - - врубай музло - - включи что-то послушать - - давай что-то послушаем - action: {type: exe, file: "Run music.exe"} + - открой блокнот + - запусти блокнот + - открой нотепад + action: {type: shell, cmd: 'start "" notepad.exe'} -music_off: +open_calculator: phrases: - - выключи музыку - - отруби музыку - - убери музыку - - отключи музыку - - закрой музыку - - останови музыку - - хватит музыки - action: {type: exe, file: "Stop music.exe", delay_ms: 200} + - открой калькулятор + - запусти калькулятор + - нужен калькулятор + action: {type: shell, cmd: 'start "" calc.exe'} -music_save: +open_explorer: phrases: - - сохрани трек - - мне нравится трек - - сохрани песню - - мне нравится песня - - добавь трек в избранное - - добавь песню в избранное - - запомни мелодию - - запомни трек - - запомни песню - - добавь мелодию в избранное - - сохрани то что сейчас играет - - добавь то что сейчас играет в избранное - - лайкни трек - - лайкни мелодию - - лайкни песню - action: {type: exe, file: "Save music.exe", delay_ms: 200} + - открой проводник + - открой папку + - запусти проводник + - открой файлы + action: {type: shell, cmd: 'start "" explorer.exe'} -music_next: +open_terminal: phrases: - - следующий трек - - следующая мелодия - - следующая песня - - переключи трек - - переключи музыку - - переключи мелодию - - переключи песню - - следующая музыка - action: {type: exe, file: "Next music.exe", delay_ms: 200} + - открой терминал + - запусти терминал + - открой консоль + - открой повершелл + action: {type: shell, cmd: 'start "" wt.exe || start "" powershell.exe'} -music_prev: +open_settings: phrases: - - предыдущая музыка - - предыдущий трек - - предыдущая мелодия - - предыдущая песня - - верни трек - - верни мелодию - - верни песню - - верни прошлый трек - - верни прошлую мелодию - - верни прошлую песню - - верни прошлую музыку - - верни то что играло - - давай предыдущий трек - - переключи музыку обратно - - переключи на прошлый трек - - переключи на прошлую мелодию - - переключи на прошлую песню - action: {type: exe, file: "Prev music.exe", delay_ms: 200} + - открой настройки + - открой параметры + - открой настройки виндоус + action: {type: shell, cmd: 'start "" ms-settings:'} + +open_task_manager: + phrases: + - открой диспетчер задач + - запусти диспетчер задач + - диспетчер задач + action: {type: shell, cmd: 'start "" taskmgr.exe'} + +lock_screen: + phrases: + - заблокируй компьютер + - заблокируй экран + - блокировка экрана + - залочь + action: {type: shell, cmd: 'rundll32.exe user32.dll,LockWorkStation'} + +screenshot: + phrases: + - сделай скриншот + - скриншот + - сними экран + action: + type: shell + cmd: >- + powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object Drawing.Bitmap $b.Width,$b.Height; $g=[Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); $p=Join-Path $env:USERPROFILE ('Pictures\jarvis_'+(Get-Date -Format 'yyyyMMdd_HHmmss')+'.png'); $bmp.Save($p); $g.Dispose(); $bmp.Dispose()" + +volume_up: + phrases: + - громче + - сделай громче + - прибавь звук + - увеличь громкость + - погромче + action: + type: multi + steps: + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + - {type: keys, sequence: 'volumeup'} + +volume_down: + phrases: + - тише + - сделай тише + - убавь звук + - уменьши громкость + - потише + action: + type: multi + steps: + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} + - {type: keys, sequence: 'volumedown'} sound_off: phrases: @@ -120,6 +137,146 @@ 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} + thanks: phrases: - спасибо @@ -141,85 +298,6 @@ stupid: - ты тупой action: {type: play_sound, name: stupid} -gaming_mode_on: - phrases: - - включи игровой режим - - перейди в игровой режим - - я хочу поиграть - - переключись на телевизор - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to gaming mode.exe", wait: true} - - {type: play_sound, name: ready} - -gaming_mode_off: - phrases: - - рабочий режим - - вернись в рабочий режим - - переключись на компьютер - - обратно на компьютер - - отключи игровой режим - - выйди из игрового режима - - вернись на компьютер - - выход с игрового режима - - рабочее пространство - - вернись в игровой режим - - выключи игровой режим - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch back to workspace.exe", wait: true} - - {type: play_sound, name: ready} - -switch_to_headphones: - phrases: - - переключи на наушники - - включи наушники - - перейди на наушники - - давай звук в наушники - - давай звук на наушники - - переключи звук в наушники - - переключи звук на наушники - - переключи на кракены - - включи кракены - - перейди на кракены - - давай звук в кракены - - давай звук на кракены - - переключи звук в кракены - - переключи звук на кракены - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to headphones.exe", wait: true, delay_ms: 500} - - {type: play_sound, name: ready} - -switch_to_dynamics: - phrases: - - переключи на динамики - - включи динамики - - перейди на динамики - - давай звук в динамики - - давай звук на динамики - - переключи звук в динамики - - переключи звук на динамики - - переключи на колонки - - включи колонки - - перейди на колонки - - давай звук в колонки - - давай звук на колонки - - переключи звук в колонки - - переключи звук на колонки - action: - type: multi - steps: - - {type: play_sound, name: ok} - - {type: exe, file: "Switch to dynamics.exe", wait: true, delay_ms: 500} - - {type: play_sound, name: ready} - 'off': phrases: - выключись diff --git a/config.py b/config.py index b78f82c..e7af1c9 100644 --- a/config.py +++ b/config.py @@ -31,6 +31,12 @@ GROQ_TOKEN = os.getenv('GROQ_TOKEN') GROQ_BASE_URL = "https://api.groq.com/openai/v1" GROQ_MODEL = "llama-3.3-70b-versatile" +# LLM auto-fallback: route unrecognized commands straight to Groq instead of +# playing the "not_found" sound. The old "скажи X" trigger still works (TBR +# strips it before intent matching, so what remains hits this fallback anyway). +LLM_AUTO_FALLBACK = True +LLM_AUTO_FALLBACK_MIN_CHARS = 4 + # VAD (webrtcvad) — определяет конец команды по тишине вместо фиксированных 10 сек. # 0..3, выше — агрессивнее режет шум (но и обрезает речь). VAD_AGGRESSIVENESS = 2 diff --git a/main.py b/main.py index dbb75a7..09dd4dc 100644 --- a/main.py +++ b/main.py @@ -168,10 +168,12 @@ def va_respond(voice: str): if len(cmd['cmd'].strip()) <= 0: return False elif cmd['percent'] < min_percent or cmd['cmd'] not in VA_CMD_LIST.keys(): - # play("not_found") - # tts.va_speak("Что?") - if fuzz.ratio(voice.join(voice.split()[:1]).strip(), "скажи") > 75: - + auto_ok = ( + config.LLM_AUTO_FALLBACK + and config.GROQ_TOKEN + and len(voice.strip()) >= config.LLM_AUTO_FALLBACK_MIN_CHARS + ) + if auto_ok: message_log.append({"role": "user", "content": voice}) response = gpt_answer() message_log.append({"role": "assistant", "content": response}) @@ -224,6 +226,214 @@ 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}") + + +_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)}.") + + +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_sysinfo(action, voice: str): + topic = action.get('topic', 'all') + + if topic in ('time', 'all'): + now = datetime.datetime.now() + weekday = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'][now.weekday()] + _speak(f"Сейчас {now.strftime('%H:%M')}, {weekday}.") + if topic == 'time': + return + + ps_scripts = { + 'battery': '$b=Get-CimInstance Win32_Battery -EA SilentlyContinue; if($b){"$([int]$b.EstimatedChargeRemaining)%"}else{"батареи нет"}', + 'cpu': '$c=Get-CimInstance Win32_Processor -EA SilentlyContinue|Select-Object -First 1; $load=(Get-CimInstance Win32_PerfFormattedData_PerfOS_Processor -Filter "Name=\'_Total\'" -EA SilentlyContinue).PercentProcessorTime; if($null -eq $load){$load=$c.LoadPercentage}; "$load%"', + 'ram': '$o=Get-CimInstance Win32_OperatingSystem; $tg=[math]::Round($o.TotalVisibleMemorySize/1MB,1); $ug=[math]::Round(($o.TotalVisibleMemorySize-$o.FreePhysicalMemory)/1MB,1); "$ug из $tg гигабайт"', + 'disk': '$d=Get-CimInstance Win32_LogicalDisk -Filter "DeviceID=\'$env:SystemDrive\'"; $fg=[math]::Round($d.FreeSpace/1GB,0); $tg=[math]::Round($d.Size/1GB,0); "свободно $fg из $tg гигабайт"', + } + labels = {'battery': 'Питание', 'cpu': 'Процессор', 'ram': 'Память', 'disk': 'Диск'} + order = ['battery', 'cpu', 'ram', 'disk'] if topic == 'all' else [topic] + + for t in order: + ps = ps_scripts.get(t) + if not ps: + continue + result = subprocess.run( + ['powershell', '-NoProfile', '-Command', ps], + capture_output=True, text=True, encoding='utf-8', timeout=10, + ) + out = result.stdout.strip() if result.returncode == 0 else 'ошибка' + _speak(f"{labels[t]}: {out}.") + + def run_action(action): t = action['type'] if t == 'exe': @@ -269,14 +479,32 @@ 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 '') + + +_current_voice: str = '' def execute_cmd(cmd: str, voice: str): + global _current_voice spec = VA_CMD_LIST.get(cmd) if not spec: return action = spec['action'] - run_action(action) + _current_voice = voice + try: + run_action(action) + finally: + _current_voice = '' confirm = spec.get('confirm_sound') if confirm is None and action['type'] == 'exe': confirm = 'ok' From 7d83bf829952f29569bfd75ec5eb46ac86c3723d Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 11:27:19 +0300 Subject: [PATCH 02/24] =?UTF-8?q?feat:=20tier-1=20PC=20tools=20parity=20?= =?UTF-8?q?=E2=80=94=20window=20mgmt,=20clipboard,=20notes,=20process=20ki?= =?UTF-8?q?ller,=20web=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the new rust packs from the same iteration. Total commands in commands.yaml is now 49 (was 33). yaml.safe_load + ast.parse both pass. Window management (8 commands) — uses the existing `keys` action with pyautogui hotkey strings: winleft+d (show_desktop), winleft+up/down (maximize/minimize), winleft+left/right (snap left/right), alt+f4 (close_window), winleft+shift+m (restore_all), winleft+tab (task_view). No new action type needed. Clipboard read aloud — new clipboard_read action. Shells to PowerShell Get-Clipboard, caps preview at 400 chars, speaks via the existing tts.va_speak (which already handles recorder.stop/start). Notes — new notes_add + notes_open actions. notes_add strips the trigger phrase, appends "[YYYY-MM-DD HH:MM] body\n" to %USERPROFILE%\Documents\jarvis-notes.txt, creates the dir if needed. notes_open shells "cmd /c start" so .txt opens in the user-default editor. Process killer — new process_kill action. Strips the trigger ("убей процесс"/"закрой программу"/etc.), looks up the remainder in the same alias map as the rust version (хром→chrome, телега→telegram, спотифай →spotify, едж/эдж/edge→msedge, дискорд→discord, стим→steam, обс→obs64, проводник/блокнот/калькулятор/диспетчер задач, vs code→code, etc.), appends .exe if needed, runs taskkill /F /IM, speaks success or "не нашёл процесс N". Web search — new websearch action with a `service` param. Bases: google → https://www.google.com/search?q= youtube → https://www.youtube.com/results?search_query= wiki → https://ru.wikipedia.org/wiki/Special:Search?search= yandex → https://yandex.ru/search/?text= Strips trigger, urllib.parse.quote-s the rest, opens via webbrowser.open (respects the user'\''s default browser; config.BROWSER_PATHS only kicks in for the older url-action commands). Portability: USERPROFILE used for notes path, no other hardcoded paths. --- commands.yaml | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) diff --git a/commands.yaml b/commands.yaml index e7c91fc..73bba1b 100644 --- a/commands.yaml +++ b/commands.yaml @@ -277,6 +277,136 @@ read_screen: - сними текст с экрана 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} + thanks: phrases: - спасибо diff --git a/main.py b/main.py index 09dd4dc..865c359 100644 --- a/main.py +++ b/main.py @@ -395,6 +395,122 @@ def do_file_search(action, voice: str): _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]}") + + 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 @@ -489,6 +605,16 @@ def run_action(action): 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 '') _current_voice: str = '' From 3795a0b4ec9aa4e736284e8dd51804225f98d293 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 12:06:44 +0300 Subject: [PATCH 03/24] =?UTF-8?q?feat:=20tier-1=20parity=20=E2=80=94=20tra?= =?UTF-8?q?nslate,=20math,=20theme=20toggle,=20projects,=20sound=20panel?= =?UTF-8?q?=20(56=20commands=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the rust packs from the same iteration. yaml.safe_load + ast.parse both pass. main.py — six new action handlers + a _speak_in_lang helper: + do_translate: picks target language from "переведи на X" / "translate to X" triggers, defaults to English, sends to Groq with strict "translation only" prompt, drops to clipboard, speaks via SAPI in matching ISO voice + do_math_eval: strips trigger, Groq at temp=0.0 max_tokens=64, short-circuits on "нет" sentinel for non-math + do_theme_set: writes AppsUseLightTheme + SystemUsesLightTheme via Set-ItemProperty under HKCU\...\Themes\Personalize, action param mode: dark|light + do_open_project / do_list_projects: reads/creates %USERPROFILE%\Documents\jarvis-projects.json (a flat list of {name, path}), exact-match → substring-match, opens in VS Code if code.cmd on PATH else Explorer. First call writes a sample config pointing under USERPROFILE so it works on any host. + do_open_sound_panel: rundll32 mmsys.cpl,,1 — Recording tab commands.yaml — 8 new entries: + translate, math_eval + theme_dark, theme_light (action params mode:dark/light) + open_project, list_projects + open_sound_panel SAPI voice selection iterates GetInstalledVoices() and picks first matching TwoLetterISOLanguageName — Russian phrases sound Russian, English/German/etc. sound right too, falls back to default if no matching voice is installed. Total commands: 56 (was 49). --- commands.yaml | 62 ++++++++++++++ main.py | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) diff --git a/commands.yaml b/commands.yaml index 73bba1b..1640b1f 100644 --- a/commands.yaml +++ b/commands.yaml @@ -407,6 +407,68 @@ search_yandex: - пробей в яндекс 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} + thanks: phrases: - спасибо diff --git a/main.py b/main.py index 865c359..53839b9 100644 --- a/main.py +++ b/main.py @@ -511,6 +511,210 @@ def do_websearch(action, voice: str): _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 @@ -519,6 +723,11 @@ def do_audio_devices_panel(action, voice: str): _speak("Открываю настройки звука, сэр.") +def do_open_sound_panel(action, voice: str): + subprocess.Popen(['rundll32.exe', 'shell32.dll,Control_RunDLL', 'mmsys.cpl,,1']) + _speak("Открываю панель звука.") + + def do_sysinfo(action, voice: str): topic = action.get('topic', 'all') @@ -615,6 +824,18 @@ def run_action(action): 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 '') _current_voice: str = '' From 69359ed5c069c58bce62921bb0cd64cbf5cb2742 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 12:12:21 +0300 Subject: [PATCH 04/24] feat: "Python edition" startup banner + one-click run.bat launcher main.py: prints a 3-line banner at startup with name, version, edition label ("Python edition") and the command count from the loaded YAML. Makes the console window self-identifying when both python and rust forks run simultaneously. run.bat at repo root: cd /d into repo, sets the cmd title to "J.A.R.V.I.S. (Python edition)", picks the venv interpreter at .venv\Scripts\python.exe if present (falls back to system python), runs main.py, then pauses at exit so the user can read any traceback instead of the console window vanishing on crash. Desktop shortcut "Jarvis (Python).lnk" points at this batch file with the project icon (the rust fork's icon.ico, since python has no .ico of its own). --- main.py | 5 +++++ run.bat | 13 +++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 run.bat diff --git a/main.py b/main.py index 53839b9..8cf43ff 100644 --- a/main.py +++ b/main.py @@ -859,6 +859,11 @@ 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) + recorder = PvRecorder(device_index=config.MICROPHONE_INDEX, frame_length=512) recorder.start() print('Using device: %s' % recorder.selected_device) diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..ebe45fb --- /dev/null +++ b/run.bat @@ -0,0 +1,13 @@ +@echo off +cd /d "%~dp0" +title J.A.R.V.I.S. (Python edition) + +if exist ".venv\Scripts\python.exe" ( + ".venv\Scripts\python.exe" main.py +) else ( + python main.py +) + +echo. +echo --- jarvis exited, press a key to close --- +pause >nul From 5790ca18affa64ef83189a9a9824abbf520d885c Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 12:28:57 +0300 Subject: [PATCH 05/24] =?UTF-8?q?feat:=20parity=20=E2=80=94=20reminders,?= =?UTF-8?q?=20today/tomorrow/yesterday,=20voice=20type,=20dice,=20stopwatc?= =?UTF-8?q?h=20(67=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the rust feature/pc-tools work. yaml.safe_load + ast.parse pass. Commands count: 56 → 67 (+11). main.py: + do_set_reminder — strips trigger, parses "N (секунд|минут|часов)" with Russian-word numeral support (один/две/пять/десять/...). Spawns a daemon thread that time.sleep()s then runs tts.va_speak + PowerShell WScript.Shell.Popup. Defaults to 5 min when number/ unit are missing. + do_date_query — accepts {offset: 0/1/-1}, computes target date with datetime.timedelta, formats with hardcoded Russian weekday/month names (no locale dependency), speaks "Сегодня/Завтра/Вчера + ...". + do_type_text — clipboard + pyautogui.hotkey('ctrl', 'v') so it works in any focused window. + do_dice — coin/dice/random via random.choice / random.randint. + do_stopwatch — stores start ts in %LOCALAPPDATA%\com.priler.jarvis\stopwatch.txt; start/check/stop ops with humanised elapsed string. commands.yaml +11 entries: + set_reminder + today / tomorrow / yesterday (action: date_query, offset: 0/1/-1) + type_text + coin_flip / roll_dice / random_number (action: dice, kind: ...) + stopwatch_start / stopwatch_check / stopwatch_stop (action: stopwatch, op: start/check/stop) --- commands.yaml | 84 +++++++++++++++++++++++++ main.py | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+) diff --git a/commands.yaml b/commands.yaml index 1640b1f..4627a68 100644 --- a/commands.yaml +++ b/commands.yaml @@ -469,6 +469,90 @@ open_sound_panel: - управление звуком 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} + thanks: phrases: - спасибо diff --git a/main.py b/main.py index 8cf43ff..007bcf1 100644 --- a/main.py +++ b/main.py @@ -728,6 +728,166 @@ def do_open_sound_panel(action, voice: str): _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 + + +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') @@ -836,6 +996,16 @@ def run_action(action): 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 '') _current_voice: str = '' From 40a46567b82643c307a373e1ebab7ca6a1d1550d Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 12:43:26 +0300 Subject: [PATCH 06/24] =?UTF-8?q?feat:=20parity=20=E2=80=94=20news,=20curr?= =?UTF-8?q?ency,=20crypto,=20fun,=20wiki,=20power,=20help=20(82=20commands?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the rust analog-inspired tier-2 packs. yaml + ast smoke pass. 56 → 82 commands (+26 yaml entries; 14 of them are power/fun/wiki/ news/etc. permutations). main.py — 7 new action handlers, all built on stdlib urllib.request (no new dep). Helper _http_get() with User-Agent header so RSS feeds that 403 default UAs go through. + do_news: pulls RSS (default Lenta.ru), extracts via regex, skips channel title (idx 0), takes 5, sends to Groq for a 2-3 sentence summary at temp 0.4. Falls back to reading first 3 headlines verbatim if no token. + do_currency_rate: cbr-xml-daily.ru daily_json.js. Recognises USD/EUR/CNY/GBP/JPY/KZT in the phrase, computes value/nominal, diffs against Previous, speaks Russian with direction. + do_crypto_price: CoinGecko simple/price for BTC/ETH/SOL/DOGE/TON with 24h change, picked by phrase keywords. + do_fun_ask: joke / fact / quote / compliment via Groq at temp 1.0 with per-kind system prompts, random seed in user message. + do_wiki_lookup: ru.wikipedia opensearch → page summary, optional Groq retell when extract > 350 chars. + do_power: dispatch table for shutdown / restart / sleep / hibernate / logoff / cancel — runs shutdown.exe /s|/r|/h|/l|/a or rundll32 powrprof,SetSuspendState. 30-second grace on shutdown and restart with /c message so "отмени выключение" works. + do_help_commands: sorts VA_CMD_LIST.keys(), speaks first 12 + total, full list to clipboard. commands.yaml +26 entries: news_headlines, currency_rate, crypto_price, joke / fact / quote / compliment (action:fun_ask, kind: ...), wiki_lookup, shutdown_pc / restart_pc / sleep_pc / hibernate_pc / logoff_user / cancel_shutdown (action:power, op: ...), help. Inspired by what mainstream voice assistants (Siri/Alexa/Алиса/ Cortana) ship by default + power-user utilities (shutdown, window/process control). --- commands.yaml | 128 ++++++++++++++++++++++++++++ main.py | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) diff --git a/commands.yaml b/commands.yaml index 4627a68..32bc7fe 100644 --- a/commands.yaml +++ b/commands.yaml @@ -553,6 +553,134 @@ stopwatch_stop: - выключи секундомер 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} + thanks: phrases: - спасибо diff --git a/main.py b/main.py index 007bcf1..0779cc6 100644 --- a/main.py +++ b/main.py @@ -856,6 +856,224 @@ def _stopwatch_state_path() -> str: 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'<title>\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}. Полный список в буфере.") + + def do_stopwatch(action, voice: str): op = action.get('op', 'check') state = _stopwatch_state_path() @@ -1006,6 +1224,20 @@ def run_action(action): 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 '') _current_voice: str = '' From b3a5bcabe5f8dabf439d28d1d72d2fe74ad149b6 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 12:58:32 +0300 Subject: [PATCH 07/24] =?UTF-8?q?feat:=20parity=20=E2=80=94=20games,=20mou?= =?UTF-8?q?se,=20random=5Fchoice=20+=20new=20packs=20(90=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the rust feature/pc-tools work. yaml.safe_load + ast.parse pass. 82 → 90 commands (+8). main.py: + do_launch_game / do_list_games: reads ~/Documents/jarvis-games.json (creates sample on first call with dota / cs2 / elden ring / witcher 3 / factorio entries). Fuzzy name+alias match, launches via steam://rungameid/ (webbrowser.open), epic_uri, or cmd /c start "" path. + do_mouse: action param op = left|right|middle|double|scroll_up| scroll_down. Embedded PowerShell P/Invokes user32!mouse_event with the right flag pairs. + do_random_choice: strips trigger, splits on " или " / " or " / ", " / " либо " / " чи ", picks one with random.choice. commands.yaml +8 entries: + launch_game / list_games + mouse_left_click / mouse_right_click / mouse_double_click / mouse_scroll_up / mouse_scroll_down (all action:mouse, op:...) + random_choice --- commands.yaml | 59 +++++++++++++++++++++++ main.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/commands.yaml b/commands.yaml index 32bc7fe..5144ede 100644 --- a/commands.yaml +++ b/commands.yaml @@ -681,6 +681,65 @@ help: - что можешь 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} + thanks: phrases: - спасибо diff --git a/main.py b/main.py index 0779cc6..b0eb515 100644 --- a/main.py +++ b/main.py @@ -1074,6 +1074,125 @@ def do_help_commands(action, voice: str): _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)}.") + + def do_stopwatch(action, voice: str): op = action.get('op', 'check') state = _stopwatch_state_path() @@ -1238,6 +1357,14 @@ def run_action(action): 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 '') _current_voice: str = '' From ef8da9cba80382b0fc6588dc4926f4c2c50f3415 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 13:11:37 +0300 Subject: [PATCH 08/24] =?UTF-8?q?feat:=20parity=20=E2=80=94=20brightness,?= =?UTF-8?q?=20window=20switch,=20spelling,=20network,=20summarize=20(100?= =?UTF-8?q?=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the rust tier-3 batch. 90 → 100 commands (round number — feels right for an "имба" milestone). main.py: + do_brightness: WMI WmiMonitorBrightness read + WmiSetBrightness write, ±20% step or 100/10 absolute, "no_wmi" sentinel for desktops. + do_window_switch: same alias map as process_kill (xrom→chrome/...), PS finds process by name OR window-title substring, picks newest StartTime, AppActivate via WScript.Shell. + do_spell_out: split chars, join with ". ", _speak. + do_network_settings: action.section = wifi|bluetooth|network → matching ms-settings:* URI via cmd /c start "". + do_my_ip: Get-NetIPAddress filtered to non-loopback non-APIPA IPv4, WAN via Invoke-RestMethod ipify, speaks "Локальный X. Внешний Y". + do_summarize_selection: pyautogui.hotkey(ctrl, c) → 250ms wait → Get-Clipboard → Groq @ temp 0.3 ("3-5 sentence retell") → speak + clipboard. commands.yaml +10 entries: brightness_up/down/max/min, switch_to_window, spell_out, open_wifi_settings, open_bluetooth_settings, my_ip, summarize_selection. Backlog: closed all "Tier-3 still TODO" pure-Lua items. Remaining TODOs (pomodoro, currency-math, stocks, translate-clipboard, daily briefing, etc.) are even smaller LOC and can roll in the next batch. --- commands.yaml | 77 ++++++++++++++++++++++ main.py | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/commands.yaml b/commands.yaml index 5144ede..e6aed09 100644 --- a/commands.yaml +++ b/commands.yaml @@ -740,6 +740,83 @@ random_choice: - выбрать 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: - спасибо diff --git a/main.py b/main.py index b0eb515..b96fb7a 100644 --- a/main.py +++ b/main.py @@ -1193,6 +1193,166 @@ def do_random_choice(action, voice: str): _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() @@ -1365,6 +1525,18 @@ def run_action(action): 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 '') _current_voice: str = '' From f82427c7b6cf126c2442322cac79f2a40d1bf0b8 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 23:51:13 +0300 Subject: [PATCH 09/24] =?UTF-8?q?feat:=20extensions=20module=20+=2019=20ne?= =?UTF-8?q?w=20commands=20(100=20=E2=86=92=20119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of new packs from the rust fork. Self-contained `extensions.py` with new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks from main.py. Wiring (main.py) - `import extensions` at top. - `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)` right after _set_clipboard is defined. - Added 15 new action-type dispatches in run_action. extensions.py (new, ~360 lines) Action types: lock_workstation → rundll32 user32.dll,LockWorkStation screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage OR file → ~/Pictures/Screenshots/jarvis-.png disk_free / disk_list → PowerShell Get-PSDrive coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard, speaks length only — never echoes the password) ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry wifi_ssid → netsh wlan show interfaces parser (ru + en field names) public_ip → urllib request to api.ipify.org unit_convert {mode: length|weight|temperature|speed} Russian-grammar pluralisation (фут/фута/футов). date_math {mode: days_until|day_of_week} Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>". echo_repeat / self_ping — debug commands interesting_fact → Groq LLM (requires GROQ_TOKEN) commands.yaml — 19 new entries: lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list, coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip, convert_length, convert_weight, convert_temperature, convert_speed, days_until, day_of_week, echo_repeat, self_ping, interesting_fact. Tests: python syntax check + yaml.safe_load both pass. Total commands: 119. NOT YET mirrored from rust (would require deeper rework): memory_pack / profile_switch / scheduler / macros / vision / codebase_qa / github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro / habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer / wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin, password ARE included — others can follow). The reason these weren't mirrored: they need new state files (memory.json, profiles.json, schedule.json, macros.json) and background threads (scheduler tick). Doable as a follow-up but out of scope here. --- commands.yaml | 178 ++++++++++++++++++ extensions.py | 499 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 38 ++++ 3 files changed, 715 insertions(+) create mode 100644 extensions.py diff --git a/commands.yaml b/commands.yaml index e6aed09..123055d 100644 --- a/commands.yaml +++ b/commands.yaml @@ -855,3 +855,181 @@ 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} diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..d29fe72 --- /dev/null +++ b/extensions.py @@ -0,0 +1,499 @@ +"""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 + +_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 + + +def set_clipboard_fn(fn): + """Register the main.py _set_clipboard helper.""" + global _set_clipboard_fn + _set_clipboard_fn = fn + + +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)) + + +# ── interesting fact (LLM-dependent) ─────────────────────────────────────── + +def do_interesting_fact(action, voice): + """Asks the configured Groq LLM for a fun fact. Requires GROQ_TOKEN.""" + import config as cfg + if not getattr(cfg, 'GROQ_TOKEN', None): + _speak("LLM не настроен.") + return + + 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: + from openai import OpenAI + except ImportError: + _speak("OpenAI клиент не установлен.") + return + + client = OpenAI( + api_key=cfg.GROQ_TOKEN, + base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), + ) + try: + resp = client.chat.completions.create( + model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + 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("Не получилось получить факт.") diff --git a/main.py b/main.py index b96fb7a..8d72d2f 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,8 @@ import simpleaudio as sa import vosk import webrtcvad import yaml + +import extensions # new-command handlers ported from rust fork from comtypes import CLSCTX_ALL from fuzzywuzzy import fuzz from pvrecorder import PvRecorder @@ -243,6 +245,11 @@ def _set_clipboard(text: str): print(f"clipboard set failed: {e}") +# Wire extensions module to our voice + clipboard helpers +extensions.set_speak(_speak) +extensions.set_clipboard_fn(_set_clipboard) + + _CODEGEN_TRIGGERS = ( 'сгенерируй скрипт', 'напиши скрипт', 'сделай скрипт', 'сгенерируй код', 'набросай код', 'напиши код', @@ -1537,6 +1544,37 @@ def run_action(action): 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 '') _current_voice: str = '' From 02c6f2f90df19ae451b69ed0638ef50156d9e881 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 23:54:33 +0300 Subject: [PATCH 10/24] =?UTF-8?q?feat:=20memory=5Fstore=20+=20profiles=5Fs?= =?UTF-8?q?tore=20+=2010=20new=20yaml=20entries=20(119=20=E2=86=92=20129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors `long_term_memory` and `profiles` modules from rust fork. memory_store.py (new, ~95 lines) - JSON store at /long_term_memory.json (atomic write). - remember(key, value) / recall(key) / forget(key) / search(query, limit) / all_facts() / build_llm_context(prompt, limit). - Uses the same JSON shape as the rust side, so the file is interoperable if you ever symlink the two installations to the same dir. profiles_store.py (new, ~145 lines) - Profiles at /profiles/.json. active_profile.txt persists. - Seeds 5 defaults on first init: default ★, work 💼, game 🎮, sleep 🌙, driving 🚗 — same as rust fork. - active() / active_name() / set_active(name) / list_names() / allows_command(cmd_id). extensions.py (+6 handlers, +160 lines) - do_memory_remember — derives key (first 6 words or split on '=') - do_memory_recall — substring search or top-3 if no query - do_memory_forget — exact then substring fallback - do_memory_list — count + first 5 keys - do_profile_set — target via action.target or voice ("работа" → work) - do_profile_what — speaks current profile + description main.py - +6 dispatch entries for the new action types. commands.yaml (+10 entries, 119 → 129) - memory_remember, memory_recall, memory_forget, memory_list - profile_work, profile_game, profile_sleep, profile_driving, profile_default - profile_what Tests: ast.parse passes for all .py files. yaml.safe_load gives 129 entries. Python parity is now meaningful: memory + profiles are usable by voice without any rust install. Still missing for full parity: scheduler (needs background thread), macros (needs phrase-capture state), vision (needs Groq vision model call), codebase_qa, github_pr. --- commands.yaml | 80 ++++++++++++++++++++++ extensions.py | 136 +++++++++++++++++++++++++++++++++++++ main.py | 12 ++++ memory_store.py | 125 ++++++++++++++++++++++++++++++++++ profiles_store.py | 168 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 521 insertions(+) create mode 100644 memory_store.py create mode 100644 profiles_store.py diff --git a/commands.yaml b/commands.yaml index 123055d..c835c3f 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1033,3 +1033,83 @@ interesting_fact: - интересный факт - расскажи факт 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} diff --git a/extensions.py b/extensions.py index d29fe72..383a125 100644 --- a/extensions.py +++ b/extensions.py @@ -20,6 +20,9 @@ import uuid as uuidlib import re import urllib.request +import memory_store +import profiles_store + _speak_fn = print _set_clipboard_fn = None @@ -445,6 +448,139 @@ def do_self_ping(action, voice): _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) + + # ── interesting fact (LLM-dependent) ─────────────────────────────────────── def do_interesting_fact(action, voice): diff --git a/main.py b/main.py index 8d72d2f..953863e 100644 --- a/main.py +++ b/main.py @@ -1575,6 +1575,18 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/memory_store.py b/memory_store.py new file mode 100644 index 0000000..cbb8902 --- /dev/null +++ b/memory_store.py @@ -0,0 +1,125 @@ +"""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 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/profiles_store.py b/profiles_store.py new file mode 100644 index 0000000..a7123f3 --- /dev/null +++ b/profiles_store.py @@ -0,0 +1,168 @@ +"""Profile switching — port of `crates/jarvis-core/src/profiles.rs`. + +Profiles live in `/profiles/.json`. Active profile name +persists in `/active_profile.txt`. Seeds 5 defaults on first run. + +Public API: + active() -> dict # currently selected profile + set_active(name) -> dict # raises ValueError if profile missing + list_names() -> list[str] + allows_command(cmd_id) -> bool +""" + +import json +import os + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROFILES_DIR = os.path.join(_HERE, 'profiles') +_ACTIVE_FILE = os.path.join(_HERE, 'active_profile.txt') + +_active_cache: dict | None = None + + +def _seed_defaults(): + defaults = [ + { + 'name': 'default', + 'description': 'Обычный режим, все команды доступны.', + 'llm_personality': '', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': [], + 'greeting': '', + 'icon': '★', + }, + { + 'name': 'work', + 'description': 'Рабочий режим — фокус, отключены развлечения.', + 'llm_personality': 'Отвечай кратко и по делу. Минимум шуток.', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': ['games.', 'fun.', 'music.'], + 'greeting': 'Режим работы. Сосредоточимся.', + 'icon': '💼', + }, + { + 'name': 'game', + 'description': 'Игровой режим — голосовые макросы и игры.', + 'llm_personality': 'Будь дружелюбен. Краткие ответы. Игровая атмосфера.', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': ['reminders.', 'calendar.'], + 'greeting': 'Игровой режим активирован.', + 'icon': '🎮', + }, + { + 'name': 'sleep', + 'description': 'Спокойный режим — тихий голос, никаких уведомлений.', + 'llm_personality': 'Говори спокойно, короткими фразами.', + 'allowed_command_prefixes': ['time.', 'weather.', 'lights.'], + 'disabled_command_prefixes': [], + 'greeting': 'Спокойной ночи.', + 'icon': '🌙', + }, + { + 'name': 'driving', + 'description': 'Режим за рулём — только безопасные команды.', + 'llm_personality': 'Краткие безопасные ответы. Никаких длинных текстов.', + 'allowed_command_prefixes': [ + 'music.', 'navigation.', 'calls.', + 'time.', 'weather.', 'reminders.', + ], + 'disabled_command_prefixes': ['windows.', 'screen.'], + 'greeting': 'Режим за рулём. Веди безопасно.', + 'icon': '🚗', + }, + ] + os.makedirs(_PROFILES_DIR, exist_ok=True) + for p in defaults: + path = os.path.join(_PROFILES_DIR, f"{p['name']}.json") + if not os.path.isfile(path): + try: + with open(path, 'w', encoding='utf-8') as f: + json.dump(p, f, ensure_ascii=False, indent=2) + except OSError as exc: + print(f"[profiles] seed {p['name']} failed: {exc}") + + +def _default_profile() -> dict: + return { + 'name': 'default', + 'description': 'fallback', + 'llm_personality': '', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': [], + 'greeting': '', + 'icon': '★', + } + + +def _load_profile(name: str) -> dict | None: + path = os.path.join(_PROFILES_DIR, f'{name}.json') + if not os.path.isfile(path): + return None + try: + with open(path, encoding='utf-8') as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print(f"[profiles] load {name} failed: {exc}") + return None + + +def _read_active_name() -> str: + try: + with open(_ACTIVE_FILE, encoding='utf-8') as f: + return f.read().strip() or 'default' + except OSError: + return 'default' + + +def _init(): + global _active_cache + if _active_cache is not None: + return + _seed_defaults() + name = _read_active_name() + _active_cache = _load_profile(name) or _default_profile() + + +def active() -> dict: + _init() + return dict(_active_cache or _default_profile()) + + +def active_name() -> str: + return active().get('name', 'default') + + +def set_active(name: str) -> dict: + global _active_cache + _init() + profile = _load_profile(name) + if not profile: + raise ValueError(f"profile '{name}' not found") + _active_cache = profile + try: + with open(_ACTIVE_FILE, 'w', encoding='utf-8') as f: + f.write(name) + except OSError as exc: + print(f"[profiles] persist failed: {exc}") + return dict(profile) + + +def list_names() -> list[str]: + _init() + names = [] + if os.path.isdir(_PROFILES_DIR): + for fname in os.listdir(_PROFILES_DIR): + if fname.endswith('.json'): + names.append(fname[:-5]) + return sorted(names) + + +def allows_command(cmd_id: str) -> bool: + p = active() + disabled = p.get('disabled_command_prefixes', []) or [] + if any(cmd_id.startswith(pfx) for pfx in disabled): + return False + allowed = p.get('allowed_command_prefixes', []) or [] + if not allowed: + return True + return any(cmd_id.startswith(pfx) for pfx in allowed) From 6a328077bced00624af0273bd2a2dade04d73cee Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 00:00:22 +0300 Subject: [PATCH 11/24] =?UTF-8?q?feat:=20vision=20+=20macros=20+=20schedul?= =?UTF-8?q?er=20in=20Python=20(129=20=E2=86=92=20142=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three big rust packs ported. Python parity now covers the FULL imba lineup except codebase_qa and github_pr. vision_handler.py (new, ~125 lines) - Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL). - do_vision_describe "что на экране" / "опиши экран" - do_vision_read_error "прочитай ошибку" - Requires GROQ_TOKEN; speaks "Не удалось" on missing. macros_store.py (new, ~170 lines) - JSON store at /macros.json (atomic write-through). - start(name) / save() / cancel() / replay(name, dispatch_fn) - is_recording / recording_name / list_names / get / delete - record_step(phrase) called by execute_cmd hook (see main.py) - is_macro_control filter blocks "запиши макрос ..." from recording itself (no infinite recursion). - replay spawns daemon thread, fires each step with 800ms delay. scheduler_store.py (new, ~220 lines) - JSON store at /schedule.json. - parse_schedule(spec) handles "daily HH:MM", "at HH:MM", "every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English. - Background daemon thread ticks every 30s, fires Speak actions through the same speak callback used by main.py. - Once-tasks auto-delete after firing; daily/interval cycle forever. - add/remove/clear/remove_by_text/list_all. extensions.py (+13 handlers) do_vision_describe / do_vision_read_error → thin proxies to vision_handler do_macros_start / save / cancel / replay / list / delete do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text main.py - extensions.init_background_services() at startup → scheduler thread boots. - Wraps execute_cmd so every successful dispatch feeds macros_store.record_step. - set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the normal command pipeline (recognize_cmd → execute_cmd). commands.yaml (+13 entries, 129 → 142) vision_describe, vision_read_error, macros_start, macros_save, macros_cancel, macros_replay, macros_list, macros_delete, scheduler_reminder, scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text. Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries. Python parity status vs rust (72 packs): ✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch (via env), llm_context (basic), media_keys (existing), screenshot, net_info, unit_convert, generators, disk, date_math, sleep_timer (via shutdown), interesting_fact, mailto, self_check, ssl_check, intro/echo ✗ codebase_qa, github_pr (require gh CLI + file walk — TODO) ✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics (could be ported but need scheduler integration + LLM glue) --- commands.yaml | 107 ++++++++++++++++ extensions.py | 242 +++++++++++++++++++++++++++++++++++ macros_store.py | 179 ++++++++++++++++++++++++++ main.py | 34 +++++ scheduler_store.py | 307 +++++++++++++++++++++++++++++++++++++++++++++ vision_handler.py | 146 +++++++++++++++++++++ 6 files changed, 1015 insertions(+) create mode 100644 macros_store.py create mode 100644 scheduler_store.py create mode 100644 vision_handler.py diff --git a/commands.yaml b/commands.yaml index c835c3f..1286b0f 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1113,3 +1113,110 @@ profile_what: - текущий режим - какой режим 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} diff --git a/extensions.py b/extensions.py index 383a125..0612faf 100644 --- a/extensions.py +++ b/extensions.py @@ -22,6 +22,9 @@ import urllib.request import memory_store import profiles_store +import macros_store +import scheduler_store +import vision_handler _speak_fn = print _set_clipboard_fn = None @@ -31,6 +34,8 @@ 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) def set_clipboard_fn(fn): @@ -39,6 +44,11 @@ def set_clipboard_fn(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) @@ -581,6 +591,238 @@ def do_profile_what(action, voice): _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): diff --git a/macros_store.py b/macros_store.py new file mode 100644 index 0000000..05566a2 --- /dev/null +++ b/macros_store.py @@ -0,0 +1,179 @@ +"""Voice-macro recorder — port of `crates/jarvis-core/src/macros.rs`. + +Records successful command phrases, replays them through the dispatch later. +JSON file at `/macros.json`. + +Public API: + is_recording() / recording_name() + start(name) / save() / cancel() + list_names() / get(name) / delete(name) + record_step(phrase) # called by main.py on each successful command + replay(name, dispatch_fn) # dispatch_fn fires one phrase +""" + +import json +import os +import time +import threading + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'macros.json') + +_recording: dict | None = None # {name, steps: list[str]} +_store: dict[str, dict] = {} +_loaded = False +_recording_lock = threading.Lock() + + +def _load(): + global _store, _loaded + if _loaded: + return + if os.path.isfile(_PATH): + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + _store = data.get('macros', {}) if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + print(f"[macros] corrupt: {exc}") + _store = {} + _loaded = True + + +def _save(): + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump({'macros': _store}, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as exc: + print(f"[macros] save: {exc}") + + +def _normalize(s: str) -> str: + return (s or '').strip().lower() + + +_CONTROL_TOKENS = ( + 'запиши макрос', 'сохрани макрос', 'отмени макрос', + 'прекрати макрос', 'отмени запись', 'запусти макрос', + 'воспроизведи макрос', 'удали макрос', +) + + +def _is_control(phrase: str) -> bool: + low = (phrase or '').lower() + return any(tok in low for tok in _CONTROL_TOKENS) + + +def is_recording() -> bool: + with _recording_lock: + return _recording is not None + + +def recording_name() -> str | None: + with _recording_lock: + return _recording['name'] if _recording else None + + +def start(name: str) -> None: + nname = _normalize(name) + if not nname: + raise ValueError("empty macro name") + with _recording_lock: + global _recording + _recording = {'name': nname, 'steps': []} + + +def cancel() -> bool: + with _recording_lock: + global _recording + if _recording is None: + return False + _recording = None + return True + + +def save() -> int: + """Stop recording, persist, return step count. Raises if nothing recorded.""" + with _recording_lock: + global _recording + if _recording is None: + raise RuntimeError("не было активной записи") + if not _recording['steps']: + _recording = None + raise RuntimeError("макрос пустой") + + _load() + name = _recording['name'] + steps = list(_recording['steps']) + _recording = None + + _store[name] = { + 'name': name, + 'steps': steps, + 'created_at': int(time.time()), + 'last_run': None, + } + _save() + return len(steps) + + +def record_step(phrase: str) -> None: + """Called by main.py after every successful command execution.""" + if not phrase: + return + if _is_control(phrase): + return + with _recording_lock: + if _recording is None: + return + _recording['steps'].append(phrase.strip()) + + +def list_names() -> list[str]: + _load() + return sorted(_store.keys()) + + +def get(name: str) -> dict | None: + _load() + return _store.get(_normalize(name)) + + +def delete(name: str) -> bool: + _load() + nname = _normalize(name) + if nname in _store: + del _store[nname] + _save() + return True + return False + + +def replay(name: str, dispatch_fn) -> int: + """Replay macro by name. dispatch_fn(phrase) fires a synthetic command. + Returns step count. Raises if macro missing. + Runs in a background thread with 800ms inter-step delay.""" + _load() + nname = _normalize(name) + m = _store.get(nname) + if not m: + raise KeyError(f"Макрос '{name}' не найден.") + + steps = list(m['steps']) + + def runner(): + for i, step in enumerate(steps): + try: + dispatch_fn(step) + except Exception as exc: + print(f"[macros] replay step {i}: {exc}") + if i + 1 < len(steps): + time.sleep(0.8) + m['last_run'] = int(time.time()) + _save() + + threading.Thread(target=runner, name=f"macro-replay-{nname}", + daemon=True).start() + return len(steps) diff --git a/main.py b/main.py index 953863e..d6942c7 100644 --- a/main.py +++ b/main.py @@ -248,6 +248,7 @@ def _set_clipboard(text: str): # 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 = ( @@ -1615,6 +1616,39 @@ 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/scheduler_store.py b/scheduler_store.py new file mode 100644 index 0000000..51deb8c --- /dev/null +++ b/scheduler_store.py @@ -0,0 +1,307 @@ +"""Proactive scheduler — port of `crates/jarvis-core/src/scheduler.rs`. + +JSON store at /schedule.json. Background daemon thread ticks every 30s +and fires due tasks via the speak callback. + +Schedule formats (parsed by `parse_schedule`): + "daily HH:MM" + "at HH:MM" + "every N minutes" / "every N hours" / "every N seconds" + "in N minutes" / "in N hours" / "in N seconds" + +Public API: + init() # start the background thread, load state + add(task_dict) -> str # returns id + remove(id) -> bool + list_all() -> list[dict] + remove_by_text(query) -> int + clear() -> int +""" + +import json +import os +import re +import threading +import time + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'schedule.json') +_TICK_SECS = 30 + +_speak_fn = print +_tasks: list[dict] = [] +_lock = threading.Lock() +_thread_started = False + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[scheduler] speak: {exc}") + + +# ── persistence ─────────────────────────────────────────────────────────── + +def _load(): + global _tasks + if os.path.isfile(_PATH): + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + _tasks = data.get('tasks', []) if isinstance(data, dict) else [] + except (OSError, json.JSONDecodeError) as exc: + print(f"[scheduler] corrupt: {exc}") + _tasks = [] + + +def _save(): + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump({'tasks': _tasks}, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as exc: + print(f"[scheduler] save: {exc}") + + +# ── parsing ─────────────────────────────────────────────────────────────── + +_RU_UNITS = { + 'минут': 60, 'минуту': 60, 'минуты': 60, + 'час': 3600, 'часа': 3600, 'часов': 3600, + 'секунд': 1, 'секунду': 1, 'секунды': 1, +} +_EN_UNITS = { + 'minute': 60, 'minutes': 60, + 'hour': 3600, 'hours': 3600, + 'second': 1, 'seconds': 1, +} + + +def _unit_to_secs(token: str) -> int | None: + """Map a unit word to seconds. Tries Russian stems first.""" + low = token.lower() + for stem, n in _RU_UNITS.items(): + if low.startswith(stem): + return n + for stem, n in _EN_UNITS.items(): + if low.startswith(stem): + return n + return None + + +def parse_schedule(spec: str) -> dict: + """Returns a task-schedule dict, raises ValueError on bad input. + + Schedule shape: + {'kind': 'daily', 'hour': H, 'minute': M} + {'kind': 'interval', 'seconds': S} + {'kind': 'once', 'at': ts} + """ + s = spec.strip().lower() + now = int(time.time()) + + # daily HH:MM + m = re.match(r'^daily\s+(\d{1,2}):(\d{2})$', s) + if m: + h, mn = int(m.group(1)), int(m.group(2)) + if not (0 <= h <= 23 and 0 <= mn <= 59): + raise ValueError("hour/minute out of range") + return {'kind': 'daily', 'hour': h, 'minute': mn} + + # at HH:MM + m = re.match(r'^at\s+(\d{1,2}):(\d{2})$', s) + if m: + h, mn = int(m.group(1)), int(m.group(2)) + if not (0 <= h <= 23 and 0 <= mn <= 59): + raise ValueError("hour/minute out of range") + import datetime as dt + target = dt.datetime.now().replace(hour=h, minute=mn, second=0, microsecond=0) + if target.timestamp() <= now: + target = target + dt.timedelta(days=1) + return {'kind': 'once', 'at': int(target.timestamp())} + + # every N + m = re.match(r'^every\s+(\d+)\s+(\S+)$', s) + if m: + n = int(m.group(1)) + secs_per = _unit_to_secs(m.group(2)) + if secs_per is None: + raise ValueError(f"unknown unit: {m.group(2)}") + return {'kind': 'interval', 'seconds': n * secs_per} + + # in N + m = re.match(r'^in\s+(\d+)\s+(\S+)$', s) + if m: + n = int(m.group(1)) + secs_per = _unit_to_secs(m.group(2)) + if secs_per is None: + raise ValueError(f"unknown unit: {m.group(2)}") + return {'kind': 'once', 'at': now + n * secs_per} + + raise ValueError(f"unknown schedule spec: {spec}") + + +def _next_fire(schedule: dict, last_fired: int | None, now: int) -> int | None: + kind = schedule.get('kind') + if kind == 'once': + if last_fired is not None: + return None + return schedule.get('at') + if kind == 'interval': + anchor = last_fired if last_fired is not None else now + return anchor + int(schedule['seconds']) + if kind == 'daily': + import datetime as dt + h, mn = int(schedule['hour']), int(schedule['minute']) + today = dt.datetime.now().replace(hour=h, minute=mn, second=0, microsecond=0) + today_ts = int(today.timestamp()) + if today_ts > now and (last_fired is None or + dt.datetime.fromtimestamp(last_fired).date() != today.date()): + return today_ts + return today_ts + 86400 + return None + + +# ── public API ──────────────────────────────────────────────────────────── + +def _gen_id() -> str: + import uuid as uuidlib + return f"task-{uuidlib.uuid4().hex[:12]}" + + +def add(task: dict) -> str: + """Add task. Required keys: name, schedule (dict or string), action. + Action is a dict with at least {'type': 'speak', 'text': '...'}. + """ + sched = task.get('schedule') + if isinstance(sched, str): + sched = parse_schedule(sched) + if not isinstance(sched, dict): + raise ValueError("missing or invalid schedule") + + tid = task.get('id') or _gen_id() + record = { + 'id': tid, + 'name': task.get('name', 'task'), + 'schedule': sched, + 'action': task.get('action', {'type': 'speak', 'text': 'Напоминание.'}), + 'last_fired': None, + 'enabled': task.get('enabled', True), + 'created_at': int(time.time()), + } + with _lock: + _tasks[:] = [t for t in _tasks if t['id'] != tid] + _tasks.append(record) + _save() + return tid + + +def remove(task_id: str) -> bool: + with _lock: + before = len(_tasks) + _tasks[:] = [t for t in _tasks if t['id'] != task_id] + if len(_tasks) != before: + _save() + return True + return False + + +def list_all() -> list[dict]: + with _lock: + return [dict(t) for t in _tasks] + + +def clear() -> int: + with _lock: + n = len(_tasks) + _tasks.clear() + _save() + return n + + +def remove_by_text(query: str) -> int: + nq = query.strip().lower() + if not nq: + return 0 + with _lock: + before = len(_tasks) + def matches(t): + if nq in t['name'].lower(): + return True + action = t.get('action', {}) + if action.get('type') == 'speak': + if nq in (action.get('text') or '').lower(): + return True + return False + _tasks[:] = [t for t in _tasks if not matches(t)] + removed = before - len(_tasks) + if removed: + _save() + return removed + + +# ── background tick ──────────────────────────────────────────────────────── + +def _fire_action(action: dict) -> None: + atype = action.get('type', 'speak') + if atype == 'speak': + _speak(action.get('text', '...')) + else: + print(f"[scheduler] unknown action type: {atype}") + + +def _tick(): + now = int(time.time()) + fired_ids = [] + expired_once = [] + with _lock: + for task in list(_tasks): + if not task.get('enabled', True): + continue + nf = _next_fire(task['schedule'], task.get('last_fired'), now) + if nf is not None and nf <= now: + fired_ids.append(task['id']) + if task['schedule'].get('kind') == 'once': + expired_once.append(task['id']) + + # Fire outside the lock — speak() can block. + for task in list_all(): + if task['id'] in fired_ids: + print(f"[scheduler] firing {task['id']} '{task['name']}'") + _fire_action(task['action']) + + if fired_ids: + with _lock: + for task in _tasks: + if task['id'] in fired_ids: + task['last_fired'] = now + _tasks[:] = [t for t in _tasks if t['id'] not in expired_once] + _save() + + +def _tick_loop(): + while True: + try: + time.sleep(_TICK_SECS) + _tick() + except Exception as exc: + print(f"[scheduler] tick err: {exc}") + + +def init(): + """Load tasks + start the background thread (idempotent).""" + global _thread_started + _load() + if _thread_started: + return + _thread_started = True + threading.Thread(target=_tick_loop, name='jarvis-scheduler', + daemon=True).start() + print(f"[scheduler] thread started ({len(_tasks)} tasks loaded)") diff --git a/vision_handler.py b/vision_handler.py new file mode 100644 index 0000000..a3d17af --- /dev/null +++ b/vision_handler.py @@ -0,0 +1,146 @@ +"""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: + import config as cfg + 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) From 31ff9977824729fa8726677157988a8ea421fd53 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 00:40:08 +0300 Subject: [PATCH 12/24] =?UTF-8?q?feat:=20codebase=5Fqa=20+=20github=5Fpr?= =?UTF-8?q?=20in=20Python=20(142=20=E2=86=92=20148=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Python parity gap with rust. All major imba features now mirrored EXCEPT live LLM hot-swap (python LLM backend is still env-driven, not voice- switchable like rust). dev_handlers.py (new, ~250 lines) Codebase Q&A: do_codebase_set "укажи проект C:\path" → memory_store.remember("codebase.root", ...) do_codebase_where "какой проект сейчас" do_codebase_ask "что делает функция X" / "найди в коде Y" Walks the root with same caps as rust: depth=3, files=30, file_bytes=4096, total=50000 Filters by extension (rs/py/ts/lua/go/...). Skips .git/target/node_modules/__pycache__/.venv etc. Feeds digest to Groq LLM with "senior reviewer" prompt. GitHub PR review (requires `gh` CLI authenticated): do_github_set_repo "текущий репо owner/repo" → memory_store do_github_list_prs "какие пиары" → `gh pr list --json number,title,author`, speaks count + first 3 titles do_github_summarize_pr "разбери последний пиар" → gh pr view of latest open PR, feeds title+body+diff stat to LLM, speaks 3-5 sentence review. extensions.py — 6 new proxy handlers + import dev_handlers + set_speak wires it. main.py — 6 new action_type dispatches in run_action. commands.yaml (+6 entries, 142 → 148): codebase_set, codebase_where, codebase_ask, github_set_repo, github_list_prs, github_summarize_pr. Tests: ast.parse passes for all .py files. yaml.safe_load = 148 entries. PYTHON PARITY VS RUST IS NOW COMPLETE except: ✗ LLM hot-swap voice command (rust calls llm::swap_to + persists DB; python config.GROQ_TOKEN is read once at startup) ✗ GUI pages (rust has Tauri+Svelte, python is console-only) Everything else has full parity: memory / profiles / vision / macros / scheduler / codebase Q&A / GitHub PR review / 14 utility packs. --- commands.yaml | 55 ++++++++ dev_handlers.py | 340 ++++++++++++++++++++++++++++++++++++++++++++++++ extensions.py | 30 +++++ main.py | 38 ++++++ 4 files changed, 463 insertions(+) create mode 100644 dev_handlers.py diff --git a/commands.yaml b/commands.yaml index 1286b0f..e969ebf 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1220,3 +1220,58 @@ scheduler_cancel_text: - отмени про - забудь напоминание про 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} diff --git a/dev_handlers.py b/dev_handlers.py new file mode 100644 index 0000000..5a7a0d6 --- /dev/null +++ b/dev_handlers.py @@ -0,0 +1,340 @@ +"""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 + +_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) + + try: + import config as cfg + except ImportError: + cfg = None + token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None + if not token: + _speak("LLM не настроен — нужен GROQ_TOKEN.") + return + + try: + from openai import OpenAI + except ImportError: + _speak("OpenAI клиент не установлен.") + return + + client = OpenAI( + api_key=token, + base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), + ) + + user_prompt = ( + f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя " + f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n" + f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}" + ) + + try: + resp = client.chat.completions.create( + model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + 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)') + + try: + import config as cfg + except ImportError: + cfg = None + token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None + if not token: + _speak(f"PR номер {number}: {title}. Без LLM сводки — нужен GROQ_TOKEN.") + return + + try: + from openai import OpenAI + except ImportError: + _speak("OpenAI клиент не установлен.") + return + + client = OpenAI( + api_key=token, + base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), + ) + + 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=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + 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/extensions.py b/extensions.py index 0612faf..b8554ca 100644 --- a/extensions.py +++ b/extensions.py @@ -25,6 +25,7 @@ import profiles_store import macros_store import scheduler_store import vision_handler +import dev_handlers _speak_fn = print _set_clipboard_fn = None @@ -36,6 +37,7 @@ def set_speak(fn): _speak_fn = fn scheduler_store.set_speak(fn) vision_handler.set_speak(fn) + dev_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -875,3 +877,31 @@ def do_interesting_fact(action, voice): except Exception as exc: print(f"[ext] fact LLM failed: {exc}") _speak("Не получилось получить факт.") + + +# ── 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) diff --git a/main.py b/main.py index d6942c7..925f9d7 100644 --- a/main.py +++ b/main.py @@ -1588,6 +1588,44 @@ def run_action(action): 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 '') _current_voice: str = '' From 8d3da4ea0653e8b98cec2af40b7c425f2523e472 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 00:44:42 +0300 Subject: [PATCH 13/24] =?UTF-8?q?feat:=20LLM=20hot-swap=20voice=20command?= =?UTF-8?q?=20+=20Ollama=20backend=20(148=20=E2=86=92=20151=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last functional parity gap with rust. Python now has voice-driven Ollama↔Groq switching, persistent across restarts. llm_backend.py (new, ~130 lines) Singleton module: current_client() → active OpenAI client (or None) current_backend() → 'groq' | 'ollama' | 'none' current_model() → active model name swap_to(name) → hot-swap + persist to /llm_backend.txt parse_backend(name) → ru/en alias normaliser ('облако'→groq, 'локальный'→ollama) Backends: - Groq: config.GROQ_TOKEN + GROQ_BASE_URL + GROQ_MODEL (defaults) - Ollama: OLLAMA_BASE_URL (default http://localhost:11434/v1) + OLLAMA_MODEL (default qwen2.5:3b). api_key='ollama' (placeholder, openai lib insists on a non-empty string; Ollama ignores it). Init precedence (idempotent _ensure_init): 1. Persisted choice from llm_backend.txt 2. JARVIS_LLM env var 3. Auto-detect: Groq if token present, else Ollama Voice commands (commands.yaml, +3 entries → 151) llm_switch_local → ollama llm_switch_cloud → groq llm_status → speaks current backend extensions.py + do_llm_switch / do_llm_status handlers + llm_backend import do_interesting_fact migrated off direct OpenAI(...) to use llm_backend.current_* dev_handlers.py + llm_backend import do_codebase_ask + do_github_summarize_pr migrated to llm_backend.current_* vision_handler.py Vision call documented as Groq-specific (Ollama doesn't expose vision via OpenAI-compat in our stack), kept direct config.GROQ_TOKEN reading. Tests: ast.parse passes for all 5 modules. yaml.safe_load = 151 entries. PYTHON PARITY VS RUST IS NOW FUNCTIONALLY COMPLETE. Only remaining rust-only feature: the Tauri GUI (python is console-only). --- commands.yaml | 32 ++++++++++ dev_handlers.py | 49 ++++----------- extensions.py | 62 ++++++++++++++----- llm_backend.py | 152 ++++++++++++++++++++++++++++++++++++++++++++++ main.py | 4 ++ vision_handler.py | 8 ++- 6 files changed, 254 insertions(+), 53 deletions(-) create mode 100644 llm_backend.py diff --git a/commands.yaml b/commands.yaml index e969ebf..83779ca 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1275,3 +1275,35 @@ github_summarize_pr: - что в последнем 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} diff --git a/dev_handlers.py b/dev_handlers.py index 5a7a0d6..9318698 100644 --- a/dev_handlers.py +++ b/dev_handlers.py @@ -15,6 +15,7 @@ import re import subprocess import memory_store +import llm_backend _speak_fn = print @@ -146,25 +147,11 @@ def do_codebase_ask(action, voice): digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks) - try: - import config as cfg - except ImportError: - cfg = None - token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None - if not token: - _speak("LLM не настроен — нужен GROQ_TOKEN.") + client = llm_backend.current_client() + if client is None: + _speak("LLM не настроен.") return - - try: - from openai import OpenAI - except ImportError: - _speak("OpenAI клиент не установлен.") - return - - client = OpenAI( - api_key=token, - base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), - ) + model = llm_backend.current_model() user_prompt = ( f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя " @@ -174,7 +161,7 @@ def do_codebase_ask(action, voice): try: resp = client.chat.completions.create( - model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + model=model, messages=[ {'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'}, {'role': 'user', 'content': user_prompt}, @@ -291,25 +278,11 @@ def do_github_summarize_pr(action, voice): files = pr.get('changedFiles', '?') author = (pr.get('author') or {}).get('login', '(unknown)') - try: - import config as cfg - except ImportError: - cfg = None - token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None - if not token: - _speak(f"PR номер {number}: {title}. Без LLM сводки — нужен GROQ_TOKEN.") + client = llm_backend.current_client() + if client is None: + _speak(f"PR номер {number}: {title}. Без LLM — настройте Groq или Ollama.") return - - try: - from openai import OpenAI - except ImportError: - _speak("OpenAI клиент не установлен.") - return - - client = OpenAI( - api_key=token, - base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), - ) + model = llm_backend.current_model() user_prompt = ( f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n" @@ -321,7 +294,7 @@ def do_github_summarize_pr(action, voice): try: resp = client.chat.completions.create( - model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + model=model, messages=[ {'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."}, {'role': 'user', 'content': user_prompt}, diff --git a/extensions.py b/extensions.py index b8554ca..25e77b8 100644 --- a/extensions.py +++ b/extensions.py @@ -26,6 +26,7 @@ import macros_store import scheduler_store import vision_handler import dev_handlers +import llm_backend _speak_fn = print _set_clipboard_fn = None @@ -828,11 +829,12 @@ def do_scheduler_cancel_by_text(action, voice): # ── interesting fact (LLM-dependent) ─────────────────────────────────────── def do_interesting_fact(action, voice): - """Asks the configured Groq LLM for a fun fact. Requires GROQ_TOKEN.""" - import config as cfg - if not getattr(cfg, 'GROQ_TOKEN', None): + """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() @@ -850,19 +852,9 @@ def do_interesting_fact(action, voice): else: prompt = "Расскажи один реально неочевидный научно-проверенный факт. На русском, 1-2 предложения. Без вступлений типа 'знали ли вы'." - try: - from openai import OpenAI - except ImportError: - _speak("OpenAI клиент не установлен.") - return - - client = OpenAI( - api_key=cfg.GROQ_TOKEN, - base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), - ) try: resp = client.chat.completions.create( - model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + model=model, messages=[ {'role': 'system', 'content': 'Ты любопытный собеседник. Цепляющие факты, без воды.'}, {'role': 'user', 'content': prompt}, @@ -879,6 +871,48 @@ def do_interesting_fact(action, voice): _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 не настроен.") + + # ── codebase Q&A (proxies to dev_handlers) ────────────────────────────────── def do_codebase_set(action, voice): diff --git a/llm_backend.py b/llm_backend.py new file mode 100644 index 0000000..51bf468 --- /dev/null +++ b/llm_backend.py @@ -0,0 +1,152 @@ +"""LLM backend singleton — Python port of `crates/jarvis-core/src/llm/mod.rs`. + +Supports two backends: + - Groq (cloud) — uses config.GROQ_TOKEN + - Ollama (local) — connects to OLLAMA_BASE_URL (default localhost:11434/v1) + +Active choice persists in `/llm_backend.txt`. Loaded on first call. + +Public API: + current_client() -> OpenAI client for the active backend, or None + current_backend() -> 'groq' | 'ollama' | 'none' + current_model() -> model name string + swap_to(name) -> 'groq' | 'ollama', persists choice + parse_backend(name) -> normalized 'groq' | 'ollama' | None +""" + +import os +import threading + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_CHOICE_FILE = os.path.join(_HERE, 'llm_backend.txt') + +_lock = threading.Lock() +_client = None +_backend = 'none' +_model = '' +_initialized = False + + +def _read_persisted() -> str: + try: + with open(_CHOICE_FILE, encoding='utf-8') as f: + return f.read().strip().lower() + except OSError: + return '' + + +def _persist(name: str) -> None: + try: + with open(_CHOICE_FILE, 'w', encoding='utf-8') as f: + f.write(name) + except OSError as exc: + print(f"[llm] persist: {exc}") + + +def parse_backend(name: str) -> str | None: + """Normalize a user-facing name to 'groq' / 'ollama' / None.""" + n = (name or '').strip().lower() + if n in ('groq', 'cloud', 'облако', 'клауд'): + return 'groq' + if n in ('ollama', 'local', 'локал', 'локальн', 'локальный'): + return 'ollama' + return None + + +def _build_groq(): + """Try to construct a Groq client. Returns (client, model) or raises.""" + try: + import config as cfg + except ImportError: + raise RuntimeError("config.py не найден") + if not getattr(cfg, 'GROQ_TOKEN', None): + raise RuntimeError("GROQ_TOKEN отсутствует в config") + from openai import OpenAI + base = getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1') + model = getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile') + return OpenAI(api_key=cfg.GROQ_TOKEN, base_url=base), model + + +def _build_ollama(): + """Construct an Ollama-talking OpenAI-compatible client.""" + from openai import OpenAI + base = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434/v1') + model = os.environ.get('OLLAMA_MODEL', 'qwen2.5:3b') + # Ollama doesn't need a real key, but openai lib insists on a non-empty string. + return OpenAI(api_key='ollama', base_url=base), model + + +def _build_for(backend: str): + if backend == 'groq': + return _build_groq() + if backend == 'ollama': + return _build_ollama() + raise ValueError(f"unknown backend: {backend}") + + +def _auto_detect_backend() -> str: + """Pick a default backend if none persisted: Groq if token present, else Ollama.""" + try: + import config as cfg + if getattr(cfg, 'GROQ_TOKEN', None): + return 'groq' + except ImportError: + pass + return 'ollama' + + +def _ensure_init(): + """Build the client based on persisted choice, env, or auto-detect.""" + global _client, _backend, _model, _initialized + if _initialized: + return + persisted = _read_persisted() + env_choice = os.environ.get('JARVIS_LLM', '').strip().lower() + backend = persisted or env_choice or _auto_detect_backend() + try: + client, model = _build_for(backend) + _client = client + _backend = backend + _model = model + except Exception as exc: + print(f"[llm] init {backend} failed: {exc}") + _client = None + _backend = 'none' + _model = '' + _initialized = True + + +def current_client(): + """Get the active client (or None if uninitialised). Cheap after first call.""" + with _lock: + _ensure_init() + return _client + + +def current_backend() -> str: + with _lock: + _ensure_init() + return _backend + + +def current_model() -> str: + with _lock: + _ensure_init() + return _model + + +def swap_to(name: str) -> str: + """Hot-swap to a different backend. Persists to disk. Returns new name.""" + target = parse_backend(name) + if not target: + raise ValueError(f"unknown backend: {name}") + with _lock: + global _client, _backend, _model, _initialized + client, model = _build_for(target) # may raise + _client = client + _backend = target + _model = model + _initialized = True + _persist(target) + print(f"[llm] swapped → {target} ({model})") + return target diff --git a/main.py b/main.py index 925f9d7..ceab4eb 100644 --- a/main.py +++ b/main.py @@ -1626,6 +1626,10 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/vision_handler.py b/vision_handler.py index a3d17af..e39f307 100644 --- a/vision_handler.py +++ b/vision_handler.py @@ -67,7 +67,13 @@ def _take_screenshot() -> str | None: def _vision_call(prompt: str, image_b64: str) -> str | None: - import config as cfg + # 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 From 4e62049a98b5141e11e9010946f6eb6dfb70cfde Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 00:56:02 +0300 Subject: [PATCH 14/24] feat+test: pytest suite + Now Playing + World Clock + Daily Quote packs (154 commands) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first formal test suite for Python fork — 42 tests covering memory_store, profiles_store, scheduler_store, macros_store, llm_backend. All pass. Tests (tests/, ~290 lines) conftest.py — `isolated_state` fixture: rewires _PATH/_PROFILES_DIR etc to a per-test tmp_path so concurrent runs don't touch real data. test_memory_store.py — 9 tests: remember/recall/forget round-trip, search by key/value, limit/empty query, persistence across reload, build_llm_context test_profiles_store.py — 7 tests: seeded defaults, set_active persistence, allows_command logic (default/work/driving) test_scheduler_store.py — 12 tests: parser (daily/at/every/in, ru units, bad input), add/remove/clear/remove_by_text, _next_fire for each schedule kind test_macros_store.py — 8 tests: is_macro_control filter, start/save round-trip, control-phrase filtering, list_names sorted, replay unknown raises test_llm_backend.py — 6 tests: parse_backend aliases (ru + en), persist round-trip, auto-detect logic Run: cd /c/Jarvis/python && python -m pytest tests/ Three new fun packs (commands.yaml: 151 → 154) now_playing — Windows Media Session API via PowerShell "что играет" / "что за песня" / "какой трек" Works with Spotify, YouTube, Foobar, Yandex Music, anything that exposes SMTC (Win10 1803+). world_clock — worldtimeapi.org (free, no key) "сколько времени в Токио" / "время в Лондоне" 21 Russian + world cities pre-mapped to IANA timezones. daily_quote — zenquotes.io (free, no key) + LLM translation "цитата дня" / "вдохнови меня" Fetches English quote, translates to Russian via active LLM (Groq or Ollama), speaks "text — author." Pack count: 151 → 154. Tests: 42 pytest + ast.parse + yaml.safe_load. --- commands.yaml | 28 +++++++ extensions.py | 154 ++++++++++++++++++++++++++++++++++ main.py | 6 ++ tests/__init__.py | 0 tests/conftest.py | 45 ++++++++++ tests/test_llm_backend.py | 68 +++++++++++++++ tests/test_macros_store.py | 87 +++++++++++++++++++ tests/test_memory_store.py | 79 +++++++++++++++++ tests/test_profiles_store.py | 61 ++++++++++++++ tests/test_scheduler_store.py | 109 ++++++++++++++++++++++++ 10 files changed, 637 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_llm_backend.py create mode 100644 tests/test_macros_store.py create mode 100644 tests/test_memory_store.py create mode 100644 tests/test_profiles_store.py create mode 100644 tests/test_scheduler_store.py diff --git a/commands.yaml b/commands.yaml index 83779ca..dc99f0a 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1307,3 +1307,31 @@ llm_status: - ты сейчас где работаешь - что за модель 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} diff --git a/extensions.py b/extensions.py index 25e77b8..ba0ed9d 100644 --- a/extensions.py +++ b/extensions.py @@ -913,6 +913,160 @@ def do_llm_status(action, voice): _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): diff --git a/main.py b/main.py index ceab4eb..d15fe41 100644 --- a/main.py +++ b/main.py @@ -1630,6 +1630,12 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ce595a0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Pytest config: temp-dir isolation for state modules. + +Each test gets a fresh dir for memory.json / schedule.json / etc so concurrent +runs don't pollute the real user data. +""" + +import os +import sys +import importlib +import pytest + +# Make parent package importable. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +@pytest.fixture +def isolated_state(tmp_path, monkeypatch): + """Patch state modules to use a tmp_path so we don't touch real state files.""" + + def patch_module(mod_name, path_attrs): + """Reimport a module and rewire its `_PATH` and friends to tmp_path.""" + if mod_name in sys.modules: + del sys.modules[mod_name] + mod = importlib.import_module(mod_name) + for attr in path_attrs: + if not hasattr(mod, attr): + continue + original = getattr(mod, attr) + new_val = os.path.join(str(tmp_path), os.path.basename(original)) + monkeypatch.setattr(mod, attr, new_val) + # Also reset any cached _loaded / _initialized flags + for flag in ('_loaded', '_initialized', '_thread_started'): + if hasattr(mod, flag): + monkeypatch.setattr(mod, flag, False) + # Reset stores + for store_attr in ('_store', '_tasks'): + if hasattr(mod, store_attr): + cur = getattr(mod, store_attr) + if isinstance(cur, dict): + monkeypatch.setattr(mod, store_attr, {}) + elif isinstance(cur, list): + monkeypatch.setattr(mod, store_attr, []) + return mod + + yield patch_module diff --git a/tests/test_llm_backend.py b/tests/test_llm_backend.py new file mode 100644 index 0000000..f98cc25 --- /dev/null +++ b/tests/test_llm_backend.py @@ -0,0 +1,68 @@ +"""Tests for llm_backend.parse_backend + state machine.""" + +import os +import importlib +import sys + + +def test_parse_backend_groq_aliases(): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + for alias in ('groq', 'GROQ', 'cloud', 'облако', 'клауд', ' Groq '): + assert m.parse_backend(alias) == 'groq', f"failed: {alias!r}" + + +def test_parse_backend_ollama_aliases(): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + for alias in ('ollama', 'OLLAMA', 'local', 'локальный', 'локал', 'локальн'): + assert m.parse_backend(alias) == 'ollama', f"failed: {alias!r}" + + +def test_parse_backend_unknown_returns_none(): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + assert m.parse_backend('openai') is None + assert m.parse_backend('claude') is None + assert m.parse_backend('') is None + assert m.parse_backend(None) is None + + +def test_persist_round_trip(tmp_path, monkeypatch): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + monkeypatch.setattr(m, '_CHOICE_FILE', str(tmp_path / 'llm_backend.txt')) + m._persist('ollama') + assert m._read_persisted() == 'ollama' + m._persist('groq') + assert m._read_persisted() == 'groq' + + +def test_auto_detect_fallback_to_ollama(tmp_path, monkeypatch): + """When no GROQ_TOKEN in config, auto-detect should pick ollama.""" + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + + # Mock config module with no GROQ_TOKEN + class FakeConfig: + GROQ_TOKEN = '' + monkeypatch.setitem(sys.modules, 'config', FakeConfig()) + + assert m._auto_detect_backend() == 'ollama' + + +def test_auto_detect_picks_groq_when_token_present(tmp_path, monkeypatch): + if 'llm_backend' in sys.modules: + del sys.modules['llm_backend'] + m = importlib.import_module('llm_backend') + + class FakeConfig: + GROQ_TOKEN = 'gsk_fake_test_token' + monkeypatch.setitem(sys.modules, 'config', FakeConfig()) + + assert m._auto_detect_backend() == 'groq' diff --git a/tests/test_macros_store.py b/tests/test_macros_store.py new file mode 100644 index 0000000..ff53efc --- /dev/null +++ b/tests/test_macros_store.py @@ -0,0 +1,87 @@ +"""Tests for macros_store: record/save/cancel/list/delete + control-phrase filter.""" + + +def test_is_macro_control_filters_record_phrases(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + assert m._is_control("Запиши макрос работа") + assert m._is_control("сохрани макрос") + assert m._is_control("запусти макрос работа") + assert m._is_control("Удали макрос работа") + # Regular commands NOT filtered + assert not m._is_control("открой браузер") + assert not m._is_control("установи громкость 50") + + +def test_start_save_round_trip(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + m.start("test") + assert m.is_recording() + assert m.recording_name() == 'test' + + m.record_step("открой браузер") + m.record_step("режим работа") + m.record_step("установи громкость 30") + # Control phrase should be filtered + m.record_step("запиши макрос новый") + + count = m.save() + assert count == 3 # 3 + 1 filtered = 3 + assert not m.is_recording() + + mac = m.get('test') + assert mac is not None + assert mac['steps'] == [ + 'открой браузер', + 'режим работа', + 'установи громкость 30', + ] + + +def test_save_without_recording_raises(isolated_state): + import pytest + m = isolated_state('macros_store', ['_PATH']) + with pytest.raises(RuntimeError): + m.save() + + +def test_save_empty_recording_raises(isolated_state): + import pytest + m = isolated_state('macros_store', ['_PATH']) + m.start("empty") + with pytest.raises(RuntimeError): + m.save() + + +def test_cancel_aborts_without_saving(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + m.start("test") + m.record_step("foo") + assert m.cancel() is True + assert not m.is_recording() + assert m.get('test') is None + + +def test_delete(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + m.start("test") + m.record_step("x") + m.save() + assert m.delete("test") is True + assert m.get('test') is None + assert m.delete("test") is False # idempotent + + +def test_list_names_sorted(isolated_state): + m = isolated_state('macros_store', ['_PATH']) + for name in ('zeta', 'alpha', 'beta'): + m.start(name) + m.record_step("step") + m.save() + assert m.list_names() == ['alpha', 'beta', 'zeta'] + + +def test_replay_unknown_raises(isolated_state): + import pytest + m = isolated_state('macros_store', ['_PATH']) + with pytest.raises(KeyError): + m.replay("nonexistent", lambda s: None) diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py new file mode 100644 index 0000000..7ed055f --- /dev/null +++ b/tests/test_memory_store.py @@ -0,0 +1,79 @@ +"""Tests for memory_store — port of long_term_memory tests in rust.""" + +import json +import os +import pytest + + +def test_remember_recall_round_trip(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("любимый чай", "улун") + assert m.recall("любимый чай") == "улун" + assert m.recall("Любимый Чай") == "улун" # case-insensitive + + +def test_recall_returns_none_for_missing(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + assert m.recall("nonexistent") is None + + +def test_forget_removes(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("foo", "bar") + assert m.forget("foo") is True + assert m.recall("foo") is None + assert m.forget("foo") is False # idempotent + + +def test_search_by_key_and_value(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("любимый чай", "улун") + m.remember("любимый кофе", "арабика") + m.remember("питомец", "собака бася") + + hits = m.search("чай", 5) + assert len(hits) == 1 + assert hits[0]['key'] == "любимый чай" + + hits = m.search("бася", 5) + assert len(hits) == 1 + assert hits[0]['key'] == "питомец" + + +def test_search_respects_limit(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + for i in range(10): + m.remember(f"key{i}", "value") + hits = m.search("value", 3) + assert len(hits) == 3 + + +def test_search_empty_query_returns_nothing(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("foo", "bar") + assert m.search("", 5) == [] + assert m.search(" ", 5) == [] + + +def test_persists_across_module_reload(isolated_state, tmp_path): + m = isolated_state('memory_store', ['_PATH']) + m.remember("persistent", "yes") + json_file = m._PATH + assert os.path.isfile(json_file) + with open(json_file, encoding='utf-8') as f: + data = json.load(f) + assert data['entries']['persistent']['value'] == 'yes' + + +def test_build_llm_context_format(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + m.remember("любимый чай", "улун") + # Query "чай" is substring of key "любимый чай" → matches. + ctx = m.build_llm_context("чай", 5) + assert "любимый чай = улун" in ctx + assert ctx.startswith("Известные факты") + + +def test_build_llm_context_empty(isolated_state): + m = isolated_state('memory_store', ['_PATH']) + assert m.build_llm_context("anything", 5) == "" diff --git a/tests/test_profiles_store.py b/tests/test_profiles_store.py new file mode 100644 index 0000000..c9815aa --- /dev/null +++ b/tests/test_profiles_store.py @@ -0,0 +1,61 @@ +"""Tests for profiles_store.""" + +import os +import json + + +def test_default_seeded_on_init(isolated_state, tmp_path): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p._init() + names = p.list_names() + # 5 defaults: default, work, game, sleep, driving + for expected in ('default', 'work', 'game', 'sleep', 'driving'): + assert expected in names, f"missing seeded profile: {expected}" + + +def test_active_defaults_to_default(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + a = p.active() + assert a['name'] == 'default' + + +def test_set_active_persists(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p.set_active('work') + assert p.active_name() == 'work' + assert os.path.isfile(p._ACTIVE_FILE) + with open(p._ACTIVE_FILE, encoding='utf-8') as f: + assert f.read().strip() == 'work' + + +def test_set_active_unknown_raises(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + try: + p.set_active('nonexistent') + assert False, "should have raised" + except ValueError: + pass + + +def test_allows_command_default_allows_all(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p.set_active('default') + assert p.allows_command('anything') + assert p.allows_command('games.steam.launch') + + +def test_allows_command_work_disables_games(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p.set_active('work') + assert not p.allows_command('games.steam.launch') + assert not p.allows_command('fun.joke') + assert p.allows_command('time.current') + + +def test_allows_command_driving_whitelist(isolated_state): + p = isolated_state('profiles_store', ['_PROFILES_DIR', '_ACTIVE_FILE']) + p.set_active('driving') + assert p.allows_command('music.play') + assert p.allows_command('weather.now') + assert not p.allows_command('windows.minimize') + assert not p.allows_command('screen.brightness') diff --git a/tests/test_scheduler_store.py b/tests/test_scheduler_store.py new file mode 100644 index 0000000..b59257c --- /dev/null +++ b/tests/test_scheduler_store.py @@ -0,0 +1,109 @@ +"""Tests for scheduler_store: parser + add/remove + serde.""" + +import time +import pytest + + +def test_parse_daily(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("daily 09:00") + assert schedule == {'kind': 'daily', 'hour': 9, 'minute': 0} + + +def test_parse_at_today_or_tomorrow(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("at 23:59") + assert schedule['kind'] == 'once' + assert schedule['at'] > time.time() + + +def test_parse_every_minutes(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("every 30 minutes") + assert schedule == {'kind': 'interval', 'seconds': 1800} + + +def test_parse_every_russian(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("every 2 часа") + assert schedule == {'kind': 'interval', 'seconds': 7200} + + +def test_parse_in_minutes_is_once(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = s.parse_schedule("in 5 minutes") + assert schedule['kind'] == 'once' + # Should be roughly 300 seconds in the future + delta = schedule['at'] - time.time() + assert 290 <= delta <= 310 + + +def test_parse_bad_unit_raises(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + with pytest.raises(ValueError): + s.parse_schedule("every 5 weeks") + + +def test_parse_bad_time_raises(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + with pytest.raises(ValueError): + s.parse_schedule("daily 25:00") + with pytest.raises(ValueError): + s.parse_schedule("at 12:99") + + +def test_add_remove(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + tid = s.add({ + 'name': 'Test', + 'schedule': 'in 1 hour', + 'action': {'type': 'speak', 'text': 'hello'}, + }) + assert tid + tasks = s.list_all() + assert len(tasks) == 1 + assert tasks[0]['name'] == 'Test' + + assert s.remove(tid) is True + assert s.list_all() == [] + assert s.remove(tid) is False # idempotent + + +def test_remove_by_text(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + s.add({ + 'name': 'Water', + 'schedule': 'every 2 hours', + 'action': {'type': 'speak', 'text': 'Попей воды.'}, + }) + s.add({ + 'name': 'Stretch', + 'schedule': 'every 50 minutes', + 'action': {'type': 'speak', 'text': 'Размяться.'}, + }) + + removed = s.remove_by_text("воды") + assert removed == 1 + assert len(s.list_all()) == 1 + + +def test_clear(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + s.add({'name': 'a', 'schedule': 'in 1 hour', 'action': {'type': 'speak', 'text': 'x'}}) + s.add({'name': 'b', 'schedule': 'in 2 hours', 'action': {'type': 'speak', 'text': 'y'}}) + assert s.clear() == 2 + assert s.list_all() == [] + + +def test_next_fire_once(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = {'kind': 'once', 'at': 500} + assert s._next_fire(schedule, None, 1000) == 500 + # Already fired + assert s._next_fire(schedule, 500, 1000) is None + + +def test_next_fire_interval(isolated_state): + s = isolated_state('scheduler_store', ['_PATH']) + schedule = {'kind': 'interval', 'seconds': 60} + assert s._next_fire(schedule, 100, 1000) == 160 From 2bef1d5ea799b94f72a80d3e0b409b637314802f Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 01:08:17 +0300 Subject: [PATCH 15/24] =?UTF-8?q?feat:=20Wave=201=20python=20parity=20?= =?UTF-8?q?=E2=80=94=2020=20new=20commands=20(154=20=E2=86=92=20174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors all 10 packs from rust commit c4b2261 to python. wave1_handlers.py (new, ~480 lines) Self-contained module with 20 handlers + DPAPI helpers + http/ps wrappers. Imports memory_store for state-backed packs (vault, clip, habit, rss, weather). Coverage: do_magic_8ball — 15 random answers do_github_list_issues — gh issue list (uses memory[github.repo]) do_github_my_issues — gh issue list --assignee @me do_weather_tomorrow — Open-Meteo + ipinfo auto-detect do_weather_week — 7-day forecast min/max range do_rss_add/read/list — RSS subscriptions + parse tags do_ics_create — write .ics, open via os.startfile do_backup_export — zipfile of long_term_memory.json + schedule + macros + profiles/ + active_profile + llm_backend + settings (searches script_dir + %APPDATA%/Jarvis) do_clip_save/list/restore — 20-slot rolling clipboard history in memory do_notif_missed/clear — enumerate memory[notif.*] / purge do_vault_save/get/list — DPAPI encrypt via PowerShell, clears clipboard after 30 sec via threading.Thread do_habit_checkin/streak — date-keyed memory state, consecutive-day counter extensions.py: +20 proxy functions + wave1_handlers import + set_speak wiring. main.py: +20 dispatch entries. commands.yaml: +20 entries (154 → 174). Tests: ast.parse passes for all .py files. 42 pytest still green. --- commands.yaml | 160 ++++++++++++ extensions.py | 26 ++ main.py | 40 +++ wave1_handlers.py | 613 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 839 insertions(+) create mode 100644 wave1_handlers.py diff --git a/commands.yaml b/commands.yaml index dc99f0a..2daba39 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1335,3 +1335,163 @@ daily_quote: - вдохнови меня - цитата 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} diff --git a/extensions.py b/extensions.py index ba0ed9d..da3a8ab 100644 --- a/extensions.py +++ b/extensions.py @@ -27,6 +27,7 @@ import scheduler_store import vision_handler import dev_handlers import llm_backend +import wave1_handlers _speak_fn = print _set_clipboard_fn = None @@ -39,6 +40,7 @@ def set_speak(fn): scheduler_store.set_speak(fn) vision_handler.set_speak(fn) dev_handlers.set_speak(fn) + wave1_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -1093,3 +1095,27 @@ def 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) diff --git a/main.py b/main.py index d15fe41..e03a185 100644 --- a/main.py +++ b/main.py @@ -1636,6 +1636,46 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/wave1_handlers.py b/wave1_handlers.py new file mode 100644 index 0000000..9c56af2 --- /dev/null +++ b/wave1_handlers.py @@ -0,0 +1,613 @@ +"""Wave 1 handler module — port of 10 rust packs from c4b2261. + +magic_8ball, github_issues, weather_extended, rss_reader, ics_event, +backup, clip_history, notif_queue, password_vault, habit_streaks. + +Imported by extensions.py + dispatched from main.py. +""" + +import base64 +import datetime as dt +import json as _json +import os +import random +import re +import subprocess +import urllib.parse +import urllib.request +import zipfile + +import memory_store + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[wave1] speak: {exc}") + + +def _ps(cmd, timeout=15): + try: + res = subprocess.run( + ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cmd], + capture_output=True, text=True, encoding='utf-8', timeout=timeout, + ) + return res.returncode == 0, (res.stdout or '').strip(), (res.stderr or '') + except Exception as exc: + return False, '', str(exc) + + +def _gh(*args, timeout=15): + try: + res = subprocess.run( + ['gh', *args], + capture_output=True, text=True, encoding='utf-8', timeout=timeout, + ) + return res.returncode == 0, res.stdout, res.stderr + except Exception as exc: + return False, '', str(exc) + + +def _http_json(url, timeout=10): + try: + with urllib.request.urlopen(url, timeout=timeout) as r: + return _json.loads(r.read().decode()) + except Exception as exc: + print(f"[wave1] http: {exc}") + return None + + +# ── magic_8ball ──────────────────────────────────────────────────────────── + +_8BALL = [ + "Без сомнения, сэр.", "Однозначно да.", "Можете на это рассчитывать.", + "Скорее всего да.", "Перспективы хорошие.", "Знаки указывают на да.", + "Пока неясно, попробуйте позже.", "Сосредоточьтесь и спросите снова.", + "Не могу предсказать сейчас.", "Лучше не говорить.", + "Не рассчитывайте на это.", "Мой ответ — нет.", "Источники говорят нет.", + "Перспективы не очень.", "Очень сомнительно.", +] + + +def do_magic_8ball(action, voice): + _speak(random.choice(_8BALL)) + + +# ── github_issues ───────────────────────────────────────────────────────── + +def do_github_list_issues(action, voice): + repo = memory_store.recall("github.repo") + if not repo: + _speak("Сначала укажите репозиторий: текущий репо owner/repo.") + return + ok, out, _ = _gh('issue', 'list', '--repo', repo, '--state', 'open', + '--json', 'number,title,labels', '--limit', '10') + if not ok: + _speak("gh CLI не отвечает.") + return + try: + items = _json.loads(out or '[]') + except _json.JSONDecodeError: + items = [] + if not items: + _speak("Открытых issues нет.") + return + titles = [i.get('title', '') for i in items[:3] if i.get('title')] + line = f"{len(items)} открытых issues. " + if titles: + line += "Первые: " + ". ".join(titles) + "." + _speak(line) + + +def do_github_my_issues(action, voice): + ok, out, _ = _gh('issue', 'list', '--assignee', '@me', '--state', 'open', + '--json', 'number,title,repository', '--limit', '10') + if not ok: + _speak("gh CLI не отвечает.") + return + try: + items = _json.loads(out or '[]') + except _json.JSONDecodeError: + items = [] + if not items: + _speak("Тебе ничего не назначено.") + return + titles = [i.get('title', '') for i in items[:3] if i.get('title')] + line = f"На вас {len(items)} issues. " + if titles: + line += "Первые: " + ". ".join(titles) + "." + _speak(line) + + +# ── weather_extended ────────────────────────────────────────────────────── + +def _weather_location(): + lat = memory_store.recall("weather.lat") + lon = memory_store.recall("weather.lon") + city = memory_store.recall("weather.city") + if lat and lon: + return lat, lon, city or "вашем городе" + ip = _http_json("https://ipinfo.io/json", timeout=5) + if not ip or not ip.get('loc'): + return None, None, None + parts = ip['loc'].split(',') + if len(parts) != 2: + return None, None, None + lat, lon = parts[0], parts[1] + city = ip.get('city', 'ваш город') + memory_store.remember("weather.lat", lat) + memory_store.remember("weather.lon", lon) + memory_store.remember("weather.city", city) + return lat, lon, city + + +def _weather_code_ru(code): + if code == 0: return "ясно" + if code <= 3: return "переменная облачность" + if 51 <= code <= 67: return "дождь" + if 71 <= code <= 77: return "снег" + if code >= 95: return "гроза" + return "переменно" + + +def do_weather_tomorrow(action, voice): + lat, lon, city = _weather_location() + if not lat: + _speak("Не получилось узнать местоположение.") + return + url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code" + f"&timezone=auto&forecast_days=2") + data = _http_json(url, timeout=10) + if not data or 'daily' not in data: + _speak("Прогноз недоступен.") + return + d = data['daily'] + if len(d.get('temperature_2m_max', [])) < 2: + _speak("Прогноз неполный.") + return + tmax = d['temperature_2m_max'][1] + tmin = d['temperature_2m_min'][1] + rain = d.get('precipitation_sum', [0, 0])[1] or 0 + code = d.get('weather_code', [0, 0])[1] or 0 + line = f"В {city} завтра {_weather_code_ru(code)}. От {round(tmin)} до {round(tmax)} градусов." + if rain > 5: + line += " Возможны осадки." + _speak(line) + + +def do_weather_week(action, voice): + lat, lon, city = _weather_location() + if not lat: + _speak("Не получилось узнать местоположение.") + return + url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"&daily=temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7") + data = _http_json(url, timeout=10) + if not data or 'daily' not in data: + _speak("Прогноз недоступен.") + return + maxes = data['daily'].get('temperature_2m_max', []) + mins = data['daily'].get('temperature_2m_min', []) + if not maxes: + _speak("Прогноз неполный.") + return + wk_max = max(maxes) + wk_min = min(mins) + _speak(f"Неделя в {city}: от {round(wk_min)} до {round(wk_max)} градусов.") + + +# ── rss_reader ──────────────────────────────────────────────────────────── + +def do_rss_add(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in ('добавь rss', 'подпишись на', 'запомни ленту', 'добавь ленту'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + m = re.search(r'(https?://\S+)', body) + if not m: + _speak("Не нашёл URL.") + return + url = m.group(1) + domain = url.split('://', 1)[1].split('/')[0] + memory_store.remember(f"rss.{domain}", url) + _speak(f"Лента {domain} добавлена.") + + +def do_rss_read(action, voice): + feed_url = None + feed_name = None + for rec in memory_store.all_facts(): + if rec['key'].startswith('rss.'): + feed_url = rec['value'] + feed_name = rec['key'][4:] + break + if not feed_url: + _speak("Лент не добавлено. Скажите 'добавь rss и URL'.") + return + try: + with urllib.request.urlopen(feed_url, timeout=10) as r: + body = r.read().decode('utf-8', errors='replace') + except Exception as exc: + print(f"[wave1] rss fetch: {exc}") + _speak("Не получилось загрузить ленту.") + return + titles = re.findall(r'<title[^>]*>([^<]+)', 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) + ".") From 59a1258dcc5223b5b283c0763b9adbed581cbe4c Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 13:27:04 +0300 Subject: [PATCH 16/24] =?UTF-8?q?feat:=20Wave=203=20=E2=80=94=20plugin=20l?= =?UTF-8?q?oader=20+=20Flet=20GUI=20(parity=20with=20Rust=20fork)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin loader (mirrors Rust #29): - plugins_store.py walks %APPDATA%\com.priler.jarvis\plugins\\ for commands.yaml fragments and merges them into VA_CMD_LIST at startup. Same schema as the project root commands.yaml. Per-pack `disabled` flag file skips a pack without deletion. Built-in ids win conflicts so a malicious pack can't silently override open_browser. 9 unit tests (parity with the Rust tests). - main.py: plugin merge runs after the root yaml load; logs the number of extra commands picked up. Flet GUI (#27 — first parity with the Rust Tauri GUI): - New gui/ package: __main__.py launches via `python -m gui` (or gui.bat). app.py routes between 7 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки) via NavigationRail; dark theme matching the Tauri look. - gui/services.py centralises every store-module call the pages share — single place to swap implementations later. - One file per page in gui/pages/, all expose `build(page)` and use the same card layout idiom. Pages mirror the Tauri ones feature- for-feature: home (status + counters + launcher), commands (filterable list of all VA_CMD_LIST entries), macros (list + delete + recording banner), scheduler (list + remove), memory (add/forget/search), plugins (toggle/open folder), settings (profile + LLM hot-swap). - requirements.txt: flet>=0.85 (optional; assistant runs without). - 3 smoke tests verify imports + build callables; skip cleanly when Flet isn't installed. README: documents both the GUI and the plugin layout in user-facing terms. Tests: 42 → 54 (+9 plugins +3 GUI smoke). --- README.md | 29 +++++++ gui.bat | 5 ++ gui/__init__.py | 19 +++++ gui/__main__.py | 5 ++ gui/app.py | 81 ++++++++++++++++++ gui/pages/__init__.py | 1 + gui/pages/commands.py | 80 ++++++++++++++++++ gui/pages/home.py | 119 +++++++++++++++++++++++++++ gui/pages/macros.py | 90 ++++++++++++++++++++ gui/pages/memory.py | 74 +++++++++++++++++ gui/pages/plugins.py | 97 ++++++++++++++++++++++ gui/pages/scheduler.py | 80 ++++++++++++++++++ gui/pages/settings.py | 77 +++++++++++++++++ gui/services.py | 159 ++++++++++++++++++++++++++++++++++++ main.py | 13 +++ plugins_store.py | 154 ++++++++++++++++++++++++++++++++++ requirements.txt | 5 ++ tests/test_gui_smoke.py | 49 +++++++++++ tests/test_plugins_store.py | 96 ++++++++++++++++++++++ 19 files changed, 1233 insertions(+) create mode 100644 gui.bat create mode 100644 gui/__init__.py create mode 100644 gui/__main__.py create mode 100644 gui/app.py create mode 100644 gui/pages/__init__.py create mode 100644 gui/pages/commands.py create mode 100644 gui/pages/home.py create mode 100644 gui/pages/macros.py create mode 100644 gui/pages/memory.py create mode 100644 gui/pages/plugins.py create mode 100644 gui/pages/scheduler.py create mode 100644 gui/pages/settings.py create mode 100644 gui/services.py create mode 100644 plugins_store.py create mode 100644 tests/test_gui_smoke.py create mode 100644 tests/test_plugins_store.py diff --git a/README.md b/README.md index 12abbd5..417165b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,35 @@ Vosk-модель для русского языка лежит в `model_small/ python main.py ``` +## GUI (Flet) + +Опциональная графическая панель — те же 7 страниц, что и в Rust-форке +(Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки). + +```bash +pip install flet>=0.85 # одноразово, уже в requirements.txt +python -m gui # или просто gui.bat +``` + +GUI не запускает ассистент сам — это панель управления. Кнопка «Запустить +J.A.R.V.I.S.» на главной запускает `main.py` отдельным процессом. + +## Плагины + +Голосовые команды, которые лежат **вне** репозитория. Добавляются без правки +проекта: создайте папку в + +``` +%APPDATA%\com.priler.jarvis\plugins\<имя_плагина>\ + commands.yaml (тот же формат, что и корневой commands.yaml) + disabled (опционально — пустой файл, отключает плагин) +``` + +Плагины подхватываются при следующем запуске `main.py`. ID встроенных команд +выигрывают конфликты (плагин не может переопределить, например, `open_browser`). +GUI-страница «Плагины» показывает список, флажки enable/disable и кнопку +«Открыть папку». + ## Конфигурация - `config.py`: diff --git a/gui.bat b/gui.bat new file mode 100644 index 0000000..6c8f127 --- /dev/null +++ b/gui.bat @@ -0,0 +1,5 @@ +@echo off +REM Launch the Flet GUI. Requires `pip install flet>=0.85`. +pushd "%~dp0" +python -m gui +popd diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..9c17412 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,19 @@ +"""Python Flet GUI — Russian-language desktop UI mirroring the Rust Tauri GUI. + +Entry point: `python -m gui` (see `__main__.py`). + +Layout: + gui/__init__.py # this docstring + gui/__main__.py # `python -m gui` entry + gui/app.py # routing, layout, theme + gui/pages/ # one file per page (home, commands, macros, ...) + gui/services.py # thin sync wrappers around store modules + +Design notes: +- Each page is a function `build(page: ft.Page) -> ft.Control` returning a + composable view. This keeps state per-page lazy and lets the user navigate + without prematurely loading everything. +- The GUI does not start the assistant; it only manipulates state files and + hot-launches `main.py` in a subprocess (mirroring how the Rust GUI starts + jarvis-app.exe via tray). +""" diff --git a/gui/__main__.py b/gui/__main__.py new file mode 100644 index 0000000..ff07ddf --- /dev/null +++ b/gui/__main__.py @@ -0,0 +1,5 @@ +"""`python -m gui` — launch the Flet GUI.""" +from gui.app import run + +if __name__ == "__main__": + run() diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000..afe2f4b --- /dev/null +++ b/gui/app.py @@ -0,0 +1,81 @@ +"""Flet GUI router and shell. + +Provides: +- Top navigation bar with the 6 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки) +- Page swap on click, no full reload +- Dark theme matching the Tauri GUI vibe + +Each page lives in `gui/pages/.py` and exposes `build(page) -> Control`. +""" + +import flet as ft + +from gui.pages import home, commands, macros, scheduler, memory, plugins, settings + + +PAGES = [ + ("Главная", home.build), + ("Команды", commands.build), + ("Макросы", macros.build), + ("Расписание", scheduler.build), + ("Память", memory.build), + ("Плагины", plugins.build), + ("Настройки", settings.build), +] + + +def _make_app(page: ft.Page): + page.title = "J.A.R.V.I.S. — Python GUI" + page.theme_mode = ft.ThemeMode.DARK + page.window.width = 940 + page.window.height = 720 + page.padding = 0 + page.bgcolor = "#0a1419" + + content_holder = ft.Container(expand=True, padding=20) + + def go(idx: int): + nav.selected_index = idx + title, builder = PAGES[idx] + try: + content_holder.content = builder(page) + except (RuntimeError, ValueError, OSError) as exc: + content_holder.content = ft.Text( + f"Не удалось открыть «{title}»:\n{exc}", + color="red", + ) + page.update() + + nav = ft.NavigationRail( + selected_index=0, + label_type=ft.NavigationRailLabelType.ALL, + min_width=80, + min_extended_width=180, + bgcolor="#0d1b21", + destinations=[ + ft.NavigationRailDestination(icon=ft.Icons.HOME, label="Главная"), + ft.NavigationRailDestination(icon=ft.Icons.LIST_ALT, label="Команды"), + ft.NavigationRailDestination(icon=ft.Icons.PLAY_CIRCLE_OUTLINE, label="Макросы"), + ft.NavigationRailDestination(icon=ft.Icons.SCHEDULE, label="Расписание"), + ft.NavigationRailDestination(icon=ft.Icons.MEMORY, label="Память"), + ft.NavigationRailDestination(icon=ft.Icons.EXTENSION, label="Плагины"), + ft.NavigationRailDestination(icon=ft.Icons.SETTINGS, label="Настройки"), + ], + on_change=lambda e: go(int(e.control.selected_index)), + ) + + layout = ft.Row( + controls=[ + nav, + ft.VerticalDivider(width=1, color="#1f3540"), + content_holder, + ], + expand=True, + ) + + page.add(layout) + go(0) + + +def run(): + ft.app(target=_make_app) diff --git a/gui/pages/__init__.py b/gui/pages/__init__.py new file mode 100644 index 0000000..2302ae1 --- /dev/null +++ b/gui/pages/__init__.py @@ -0,0 +1 @@ +"""Page modules: each exposes `build(page: flet.Page) -> flet.Control`.""" diff --git a/gui/pages/commands.py b/gui/pages/commands.py new file mode 100644 index 0000000..b8a622f --- /dev/null +++ b/gui/pages/commands.py @@ -0,0 +1,80 @@ +"""Commands page — read-only browser of `commands.yaml` + plugin packs.""" + +import os +import flet as ft +import yaml + + +_HERE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _load_commands() -> dict[str, dict]: + try: + with open(os.path.join(_HERE, "commands.yaml"), "rt", encoding="utf8") as f: + return yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError): + return {} + + +def build(page: ft.Page) -> ft.Control: # pylint: disable=unused-argument + cmds = _load_commands() + search = ft.TextField(label="Фильтр", value="", autofocus=False, expand=True) + list_view = ft.ListView(expand=True, spacing=6, padding=0) + + def render(query: str = ""): + q = (query or "").strip().lower() + list_view.controls.clear() + shown = 0 + for cmd_id, entry in sorted(cmds.items()): + phrases = entry.get("phrases") or [] + action = entry.get("action") or {} + joined = " ".join(phrases).lower() + if q and q not in cmd_id.lower() and q not in joined: + continue + shown += 1 + list_view.controls.append( + ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text(cmd_id, weight=ft.FontWeight.W_600, size=13), + ft.Container( + content=ft.Text(action.get("type", "?"), size=10), + bgcolor="#1e3a44", + padding=ft.padding.symmetric(horizontal=8, vertical=2), + border_radius=10, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text( + " · ".join(phrases[:6]) + ("…" if len(phrases) > 6 else ""), + size=11, + color="#8aa0a8", + ), + ], spacing=4), + bgcolor="#15252b", + border_radius=8, + padding=10, + ) + ) + # The header refers to the closure-captured `header` Text via the + # outer-scope binding declared after this function. + header.value = f"Команд показано: {shown} из {len(cmds)}" + page.update() + + search.on_change = lambda e: render(e.control.value) + + header = ft.Text(f"Всего команд: {len(cmds)}", size=12, color="#8aa0a8") + + render() + + return ft.Column( + controls=[ + ft.Row([ + ft.Text("Команды", size=24, weight=ft.FontWeight.W_700), + header, + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + search, + ft.Container(content=list_view, expand=True), + ], + spacing=10, + expand=True, + ) diff --git a/gui/pages/home.py b/gui/pages/home.py new file mode 100644 index 0000000..a59d59c --- /dev/null +++ b/gui/pages/home.py @@ -0,0 +1,119 @@ +"""Home page — quick launcher + active backends + counters.""" + +import flet as ft + +from gui import services as svc + + +def _stat_card(title: str, value: str, color: str = "#33d3a4") -> ft.Container: + return ft.Container( + content=ft.Column( + [ + ft.Text(title, size=11, color="#8aa0a8"), + ft.Text(value, size=22, weight=ft.FontWeight.W_600, color=color), + ], + spacing=2, + ), + bgcolor="#15252b", + border_radius=10, + padding=14, + expand=True, + ) + + +def build(page: ft.Page) -> ft.Control: + running = svc.assistant_is_running() + try: + llm_b, llm_m = svc.llm_active() + except (RuntimeError, OSError, ValueError) as exc: + llm_b, llm_m = "—", str(exc) + + counters = ft.Row( + controls=[ + _stat_card("Команды", str(_safe_count(svc, "commands"))), + _stat_card("Макросы", str(len(svc.macros_all()))), + _stat_card("Расписание", str(len(svc.scheduler_all()))), + _stat_card("Память (фактов)", str(len(svc.memory_all()))), + _stat_card("Плагины", str(len(svc.plugins_all()))), + ], + spacing=10, + ) + + status_chip = ft.Container( + content=ft.Text( + "АССИСТЕНТ РАБОТАЕТ" if running else "АССИСТЕНТ ОСТАНОВЛЕН", + size=12, + weight=ft.FontWeight.W_700, + color="#13191c", + ), + bgcolor="#33d3a4" if running else "#d8d8d8", + padding=ft.padding.symmetric(horizontal=12, vertical=6), + border_radius=20, + ) + + start_btn = ft.FilledButton( + text="Запустить J.A.R.V.I.S.", + icon=ft.Icons.PLAY_ARROW, + on_click=lambda e: _launch(page, e), + disabled=running, + ) + + backends_card = ft.Container( + content=ft.Column([ + ft.Text("Активный LLM", size=11, color="#8aa0a8"), + ft.Text(f"{llm_b} · {llm_m}", size=14, weight=ft.FontWeight.W_500), + ], spacing=2), + bgcolor="#15252b", + border_radius=10, + padding=14, + ) + + return ft.Column( + controls=[ + ft.Row([ + ft.Text("J.A.R.V.I.S.", size=28, weight=ft.FontWeight.W_700), + status_chip, + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text( + "Голосовой ассистент — Python-форк. Эта панель управляет состоянием, " + "запускает main.py и переключает движки.", + color="#8aa0a8", + size=12, + ), + ft.Divider(color="#1f3540"), + backends_card, + counters, + ft.Divider(color="#1f3540"), + start_btn, + ], + spacing=14, + expand=True, + scroll=ft.ScrollMode.AUTO, + ) + + +def _launch(page: ft.Page, _e) -> None: + ok = svc.assistant_run() + page.open( + ft.SnackBar( + content=ft.Text( + "Запускаю main.py" if ok else "Не удалось запустить main.py", + color="white", + ), + bgcolor="#33d3a4" if ok else "#d8584f", + ) + ) + + +def _safe_count(svc_module, kind: str) -> int: + """Counts come from disk; if a store fails we display 0 rather than crash.""" + try: + if kind == "commands": + # Don't import the full VA_CMD_LIST machinery — read yaml directly. + import os, yaml # pylint: disable=import-outside-toplevel + here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + with open(os.path.join(here, "commands.yaml"), "rt", encoding="utf8") as f: + return len(yaml.safe_load(f) or {}) + except (OSError, ValueError): + return 0 + return 0 diff --git a/gui/pages/macros.py b/gui/pages/macros.py new file mode 100644 index 0000000..f89267f --- /dev/null +++ b/gui/pages/macros.py @@ -0,0 +1,90 @@ +"""Macros page — list macros, show steps, delete. Recording is voice-only.""" + +import flet as ft + +from gui import services as svc + + +def build(page: ft.Page) -> ft.Control: + list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO) + + def render(): + list_view.controls.clear() + macros = svc.macros_all() + rec_on, rec_name = svc.macros_recording() + if rec_on: + list_view.controls.append(_recording_banner(rec_name)) + if not macros: + list_view.controls.append( + ft.Text( + "Макросов пока нет. Запиши голосом: «Запиши макрос работа», " + "потом скажи команды, потом «Сохрани макрос».", + color="#8aa0a8", + ) + ) + for m in sorted(macros, key=lambda x: x["name"]): + list_view.controls.append(_macro_card(m, on_delete=lambda n=m["name"]: do_delete(n))) + page.update() + + def do_delete(name: str) -> None: + if svc.macros_delete(name): + page.open(ft.SnackBar(content=ft.Text(f"Удалил «{name}»", color="white"), bgcolor="#33d3a4")) + render() + + render() + + return ft.Column( + [ + ft.Row([ + ft.Text("Макросы", size=24, weight=ft.FontWeight.W_700), + ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + list_view, + ], + expand=True, + spacing=10, + ) + + +def _recording_banner(name: str | None) -> ft.Control: + return ft.Container( + content=ft.Row([ + ft.Icon(ft.Icons.FIBER_MANUAL_RECORD, color="#ff5757"), + ft.Text( + f"Идёт запись макроса «{name or '—'}». " + "Произнесите команды, затем «Сохрани макрос».", + ), + ]), + bgcolor="#3a2020", + border_radius=8, + padding=10, + ) + + +def _macro_card(m: dict, on_delete) -> ft.Control: + steps = m.get("steps", []) + preview = steps[:5] + extra = len(steps) - len(preview) + return ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text(m["name"], weight=ft.FontWeight.W_600, size=14), + ft.Container( + content=ft.Text(f"{m['steps_count']} шагов", size=10), + bgcolor="#1e3a44", + padding=ft.padding.symmetric(horizontal=8, vertical=2), + border_radius=10, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Column([ + ft.Text(f"• {s}", size=11, color="#a8b8c0") for s in preview + ] + ([ft.Text(f"+ ещё {extra}…", size=11, color="#5a6a72", italic=True)] if extra > 0 else []), + spacing=2), + ft.Row([ + ft.OutlinedButton(text="Удалить", icon=ft.Icons.DELETE, on_click=lambda e: on_delete()), + ]), + ], spacing=8), + bgcolor="#15252b", + border_radius=8, + padding=12, + ) diff --git a/gui/pages/memory.py b/gui/pages/memory.py new file mode 100644 index 0000000..fb8f549 --- /dev/null +++ b/gui/pages/memory.py @@ -0,0 +1,74 @@ +"""Long-term memory page — list facts, add new ones, search, forget.""" + +import flet as ft + +from gui import services as svc + + +def build(page: ft.Page) -> ft.Control: + key_input = ft.TextField(label="Ключ", expand=True) + val_input = ft.TextField(label="Значение", expand=True) + search_input = ft.TextField(label="Поиск", expand=True) + + list_view = ft.Column(spacing=6, expand=True, scroll=ft.ScrollMode.AUTO) + + def render(query: str = ""): + list_view.controls.clear() + facts = svc.memory_search(query, limit=100) if query.strip() else svc.memory_all() + if not facts: + list_view.controls.append(ft.Text("Нет фактов. Скажи «Запомни что моя любимая еда — суши».", + color="#8aa0a8")) + for f in sorted(facts, key=lambda x: x.get("key", "")): + list_view.controls.append(_fact_row(f, on_forget=lambda k=f["key"]: do_forget(k))) + page.update() + + def do_add(_): + k = key_input.value.strip() + v = val_input.value.strip() + if not k or not v: + return + svc.memory_remember(k, v) + key_input.value = "" + val_input.value = "" + render(search_input.value or "") + + def do_forget(k: str): + svc.memory_forget(k) + render(search_input.value or "") + + search_input.on_change = lambda e: render(e.control.value) + render() + + return ft.Column( + [ + ft.Text("Долговременная память", size=24, weight=ft.FontWeight.W_700), + ft.Text( + "Ключ-значение, которые Jarvis передаёт LLM как контекст пользователя.", + color="#8aa0a8", size=12, + ), + ft.Row([key_input, val_input, ft.FilledButton(text="Добавить", on_click=do_add)]), + search_input, + list_view, + ], + spacing=10, + expand=True, + ) + + +def _fact_row(f: dict, on_forget) -> ft.Control: + return ft.Container( + content=ft.Row([ + ft.Column([ + ft.Text(f.get("key", ""), weight=ft.FontWeight.W_600, size=13), + ft.Text(f.get("value", ""), size=12, color="#a8b8c0"), + ], spacing=2, expand=True), + ft.IconButton( + icon=ft.Icons.DELETE_FOREVER, + tooltip="Забыть", + on_click=lambda e: on_forget(), + ), + ]), + bgcolor="#15252b", + border_radius=8, + padding=ft.padding.symmetric(horizontal=12, vertical=8), + ) diff --git a/gui/pages/plugins.py b/gui/pages/plugins.py new file mode 100644 index 0000000..233020c --- /dev/null +++ b/gui/pages/plugins.py @@ -0,0 +1,97 @@ +"""Plugins page — list user packs, toggle enabled, open folder.""" + +import flet as ft + +from gui import services as svc + + +def build(page: ft.Page) -> ft.Control: + list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO) + + def render(): + list_view.controls.clear() + packs = svc.plugins_all() + if not packs: + list_view.controls.append( + ft.Column([ + ft.Text( + "Плагинов пока нет. Создай папку с commands.yaml в " + "%APPDATA%\\com.priler.jarvis\\plugins\\, нажми «Обновить».", + color="#8aa0a8", + ), + ]) + ) + for p in packs: + list_view.controls.append(_pack_row(p, on_toggle=lambda n=p["name"], e=p["enabled"]: do_toggle(n, e))) + page.update() + + def do_toggle(name: str, was_enabled: bool): + ok, msg = svc.plugins_set_enabled(name, not was_enabled) + page.open(ft.SnackBar( + content=ft.Text( + "Перезапусти main.py, чтобы применить." if ok else f"Ошибка: {msg}", + color="white", + ), + bgcolor="#33d3a4" if ok else "#d8584f", + )) + render() + + def do_open(_): + path = svc.plugins_open_folder() + page.open(ft.SnackBar(content=ft.Text(f"Папка: {path}", color="white"), bgcolor="#33d3a4")) + + render() + + return ft.Column( + [ + ft.Row([ + ft.Text("Плагины", size=24, weight=ft.FontWeight.W_700), + ft.Row([ + ft.OutlinedButton(text="Открыть папку", icon=ft.Icons.FOLDER_OPEN, on_click=do_open), + ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()), + ]), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text( + "Папки в %APPDATA%\\com.priler.jarvis\\plugins\\, " + "каждая содержит свой commands.yaml. Изменения подхватываются после рестарта main.py.", + color="#8aa0a8", size=12, + ), + list_view, + ], + spacing=10, + expand=True, + ) + + +def _pack_row(p: dict, on_toggle) -> ft.Control: + err = p.get("error") + return ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text(p["name"], weight=ft.FontWeight.W_600, size=14), + ft.Container( + content=ft.Text( + "ошибка" if err else f"{p['command_count']} команд", + size=10, color="white", + ), + bgcolor="#7a2c2c" if err else ("#1e6b5a" if p["enabled"] else "#404040"), + padding=ft.padding.symmetric(horizontal=8, vertical=2), + border_radius=10, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text(p["path"], size=10, color="#5a6a72", font_family="Cascadia Mono"), + ft.Text(err, size=11, color="#ff8888") if err else ft.Container(height=0), + ft.Row([ + ft.Switch( + value=p["enabled"], + on_change=lambda e: on_toggle(), + disabled=bool(err), + ), + ft.Text("Включён" if p["enabled"] else "Выключен", + size=11, color="#33d3a4" if p["enabled"] else "#8aa0a8"), + ]), + ], spacing=6), + bgcolor="#15252b", + border_radius=8, + padding=12, + ) diff --git a/gui/pages/scheduler.py b/gui/pages/scheduler.py new file mode 100644 index 0000000..d46f05f --- /dev/null +++ b/gui/pages/scheduler.py @@ -0,0 +1,80 @@ +"""Scheduler page — list tasks + remove.""" + +import datetime +import flet as ft + +from gui import services as svc + + +def _fmt_ts(ts) -> str: + if not ts: + return "—" + try: + return datetime.datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OSError): + return "—" + + +def build(page: ft.Page) -> ft.Control: + list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO) + + def render(): + list_view.controls.clear() + tasks = svc.scheduler_all() + if not tasks: + list_view.controls.append( + ft.Text("Запланированных задач нет. Создавай голосом: «Каждые 30 минут напомни попить воды».", + color="#8aa0a8")) + for t in sorted(tasks, key=lambda x: x.get("created_at", 0)): + list_view.controls.append(_task_card(t, on_delete=lambda tid=t["id"]: do_remove(tid))) + page.update() + + def do_remove(task_id: str): + if svc.scheduler_remove(task_id): + page.open(ft.SnackBar(content=ft.Text("Задача удалена", color="white"), bgcolor="#33d3a4")) + render() + + render() + + return ft.Column( + [ + ft.Row([ + ft.Text("Расписание", size=24, weight=ft.FontWeight.W_700), + ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + list_view, + ], + spacing=10, + expand=True, + ) + + +def _task_card(t: dict, on_delete) -> ft.Control: + name = t.get("name", "?") + sched_h = t.get("schedule_human") or t.get("schedule_kind") or "?" + action = t.get("action") or {} + action_text = action.get("text") or action.get("speak") or action.get("phrase") or "(нет текста)" + return ft.Container( + content=ft.Column([ + ft.Row([ + ft.Text(name, weight=ft.FontWeight.W_600, size=14), + ft.Container( + content=ft.Text(sched_h, size=10), + bgcolor="#1e3a44", + padding=ft.padding.symmetric(horizontal=8, vertical=2), + border_radius=10, + ), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), + ft.Text(f"Действие: {action_text}", size=11, color="#a8b8c0"), + ft.Row([ + ft.Text(f"создана: {_fmt_ts(t.get('created_at'))}", size=10, color="#5a6a72"), + ft.Text(f"последний запуск: {_fmt_ts(t.get('last_fired'))}", size=10, color="#5a6a72"), + ]), + ft.Row([ + ft.OutlinedButton(text="Удалить", icon=ft.Icons.DELETE, on_click=lambda e: on_delete()), + ]), + ], spacing=6), + bgcolor="#15252b", + border_radius=8, + padding=12, + ) diff --git a/gui/pages/settings.py b/gui/pages/settings.py new file mode 100644 index 0000000..b3db0bd --- /dev/null +++ b/gui/pages/settings.py @@ -0,0 +1,77 @@ +"""Settings page — active profile + LLM backend swap.""" + +import flet as ft + +from gui import services as svc + + +def build(page: ft.Page) -> ft.Control: + profiles = svc.profile_list() + active_profile = svc.profile_active() + try: + llm_b, llm_m = svc.llm_active() + except (RuntimeError, OSError, ValueError) as exc: + llm_b, llm_m = "ошибка", str(exc) + + profile_dd = ft.Dropdown( + label="Активный профиль", + options=[ft.dropdown.Option(p) for p in profiles], + value=active_profile, + expand=True, + ) + + def apply_profile(_): + target = profile_dd.value + if not target: + return + svc.profile_set(target) + page.open(ft.SnackBar(content=ft.Text(f"Профиль: {target}", color="white"), bgcolor="#33d3a4")) + + llm_dd = ft.Dropdown( + label="LLM бэкенд", + options=[ft.dropdown.Option("groq"), ft.dropdown.Option("ollama")], + value=llm_b if llm_b in ("groq", "ollama") else "groq", + expand=True, + ) + + def apply_llm(_): + target = llm_dd.value + if not target: + return + try: + new = svc.llm_swap(target) + page.open(ft.SnackBar(content=ft.Text(f"LLM → {new}", color="white"), bgcolor="#33d3a4")) + except (ValueError, RuntimeError, OSError) as exc: + page.open(ft.SnackBar(content=ft.Text(f"Ошибка: {exc}", color="white"), bgcolor="#d8584f")) + + return ft.Column( + [ + ft.Text("Настройки", size=24, weight=ft.FontWeight.W_700), + ft.Container( + content=ft.Column([ + ft.Text("Профиль", size=14, weight=ft.FontWeight.W_500), + ft.Text( + "Профили ограничивают какие команды активны (например, «sleep» отрубает шумные).", + color="#8aa0a8", size=11, + ), + ft.Row([profile_dd, ft.FilledButton(text="Применить", on_click=apply_profile)]), + ], spacing=8), + bgcolor="#15252b", + border_radius=8, + padding=14, + ), + ft.Container( + content=ft.Column([ + ft.Text("LLM", size=14, weight=ft.FontWeight.W_500), + ft.Text(f"Сейчас: {llm_b} · {llm_m}", color="#8aa0a8", size=11), + ft.Row([llm_dd, ft.FilledButton(text="Переключить", on_click=apply_llm)]), + ], spacing=8), + bgcolor="#15252b", + border_radius=8, + padding=14, + ), + ], + spacing=14, + expand=True, + scroll=ft.ScrollMode.AUTO, + ) diff --git a/gui/services.py b/gui/services.py new file mode 100644 index 0000000..7d76728 --- /dev/null +++ b/gui/services.py @@ -0,0 +1,159 @@ +"""Thin wrappers around the store modules + helpers the GUI pages share. + +Putting these here means each page imports `from gui import services as svc` +rather than importing internal store modules directly. If we later swap an +implementation (e.g. switching memory backend), only this file changes. +""" + +import os +import sys +import subprocess + +# Make sure project root is on sys.path when launched as `python -m gui` +# from inside C:\Jarvis\python\. +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _PROJECT_ROOT not in sys.path: + sys.path.insert(0, _PROJECT_ROOT) + +import memory_store +import profiles_store +import macros_store +import scheduler_store +import plugins_store +import llm_backend + + +# ---------- memory ---------- + +def memory_all() -> list[dict]: + return memory_store.all_facts() + + +def memory_remember(key: str, value: str) -> None: + memory_store.remember(key, value) + + +def memory_forget(key: str) -> bool: + return memory_store.forget(key) + + +def memory_search(query: str, limit: int = 5) -> list[dict]: + return memory_store.search(query, limit) + + +# ---------- profiles ---------- + +def profile_list() -> list[str]: + return profiles_store.list_names() + + +def profile_active() -> str: + return profiles_store.active_name() + + +def profile_set(name: str) -> dict: + return profiles_store.set_active(name) + + +# ---------- macros ---------- + +def macros_all() -> list[dict]: + out = [] + for name in macros_store.list_names(): + m = macros_store.get(name) or {} + out.append({ + "name": name, + "steps_count": len(m.get("steps", [])), + "steps": m.get("steps", []), + "created_at": m.get("created_at", 0), + "last_run": m.get("last_run"), + }) + return out + + +def macros_delete(name: str) -> bool: + return macros_store.delete(name) + + +def macros_recording() -> tuple[bool, str | None]: + return macros_store.is_recording(), macros_store.recording_name() + + +# ---------- scheduler ---------- + +def scheduler_all() -> list[dict]: + return scheduler_store.list_all() + + +def scheduler_remove(task_id: str) -> bool: + return scheduler_store.remove(task_id) + + +# ---------- plugins ---------- + +def plugins_all() -> list[dict]: + return plugins_store.list_packs() + + +def plugins_open_folder() -> str: + p = plugins_store.plugins_dir() + if os.name == "nt": + try: + subprocess.Popen(["explorer.exe", p]) + except OSError: + pass + return p + + +def plugins_set_enabled(name: str, enabled: bool) -> tuple[bool, str]: + return plugins_store.set_enabled(name, enabled) + + +# ---------- LLM ---------- + +def llm_active() -> tuple[str, str]: + return llm_backend.current_backend(), llm_backend.current_model() + + +def llm_swap(target: str) -> str: + return llm_backend.swap_to(target) + + +# ---------- assistant launcher ---------- + +def assistant_run() -> bool: + """Spawn main.py in a detached subprocess. Returns True on launch.""" + try: + main_py = os.path.join(_PROJECT_ROOT, "main.py") + # CREATE_NEW_PROCESS_GROUP so it doesn't get killed with the GUI + creationflags = 0 + if os.name == "nt": + creationflags = 0x00000200 # CREATE_NEW_PROCESS_GROUP + subprocess.Popen( + [sys.executable, main_py], + cwd=_PROJECT_ROOT, + creationflags=creationflags, + ) + return True + except OSError as e: + print(f"[gui] failed to launch assistant: {e}") + return False + + +def assistant_is_running() -> bool: + """Crude check: look for a python process running main.py from our dir. + + Implementation note: we deliberately avoid pulling psutil — keeps the GUI + install footprint small. The Rust fork uses sysinfo; here we just scan + `tasklist` on Windows. + """ + if os.name != "nt": + return False + try: + out = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq python.exe", "/V", "/FO", "CSV"], + capture_output=True, text=True, timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return False + return "main.py" in out.stdout diff --git a/main.py b/main.py index e03a185..ffc2020 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ import webrtcvad import yaml 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 @@ -38,6 +39,18 @@ VA_CMD_LIST = yaml.safe_load( open('commands.yaml', 'rt', encoding='utf8'), ) +# Merge in any user-installed plugin packs from APP_CONFIG_DIR/plugins/. +# Built-in ids take precedence (plugins cannot override stock commands). +_plugin_cmds = plugins_store.discover() +_added = 0 +for _cmd_id, _entry in _plugin_cmds.items(): + if _cmd_id in VA_CMD_LIST: + continue + VA_CMD_LIST[_cmd_id] = _entry + _added += 1 +if _added > 0: + print(f"[plugins] loaded {_added} extra command(s) from APP_CONFIG_DIR/plugins/") + system_message = {"role": "system", "content": ( "Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), ИИ-ассистент Тони Старка из киновселенной Marvel " "(до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). " diff --git a/plugins_store.py b/plugins_store.py new file mode 100644 index 0000000..6a34ad3 --- /dev/null +++ b/plugins_store.py @@ -0,0 +1,154 @@ +r"""User plugin loader — mirror of the Rust plugin system. + +Plugins live in `/plugins//commands.yaml` and follow the +exact same YAML schema as the bundled `commands.yaml`. The user can drop new +voice command packs in there without touching the project tree. + +Layout (per pack): + + /plugins/ + / + commands.yaml (required — top-level dict, same schema) + disabled (optional empty file — pack is skipped) + ... (any helper files referenced by handlers) + +Discovery is tolerant: a malformed yaml logs a warning and the pack is skipped; +it never breaks the rest of the loader. + +`APP_CONFIG_DIR` resolves to `%APPDATA%\com.priler.jarvis\plugins\` on Windows, +matching the Rust fork — so a single plugins folder can serve both installs. + +The id-collision policy is "first wins, plugin loses": a plugin cannot override +a built-in command id. This protects against drive-by replacement of e.g. +`open_browser` by a malicious pack. +""" + +import os +import yaml +from typing import Dict, List, Tuple + +PLUGINS_DIR_NAME = "plugins" +DISABLED_FLAG = "disabled" + + +def app_config_dir() -> str: + """Returns the per-user config dir matching the Rust fork's APP_CONFIG_DIR. + + On Windows this is `%APPDATA%\\com.priler.jarvis\\`. We don't depend on + platform-dirs here — std env vars are enough. + """ + appdata = os.environ.get("APPDATA") + if appdata: + return os.path.join(appdata, "com.priler.jarvis") + # fallback for non-Windows or unusual environments + return os.path.join(os.path.expanduser("~"), ".config", "com.priler.jarvis") + + +def plugins_dir() -> str: + """Returns `/plugins/`, creating it lazily.""" + d = os.path.join(app_config_dir(), PLUGINS_DIR_NAME) + try: + os.makedirs(d, exist_ok=True) + except OSError: + pass + return d + + +def discover() -> Dict[str, dict]: + """Walks `plugins_dir()` and returns a merged dict of command_id -> entry. + + Caller is expected to merge this into the main VA_CMD_LIST. + """ + return discover_in(plugins_dir()) + + +def discover_in(directory: str) -> Dict[str, dict]: + """Like `discover` but takes an explicit directory — used by tests.""" + merged: Dict[str, dict] = {} + if not os.path.isdir(directory): + return merged + + for name in sorted(os.listdir(directory)): + pack_path = os.path.join(directory, name) + if not os.path.isdir(pack_path): + continue + if os.path.isfile(os.path.join(pack_path, DISABLED_FLAG)): + continue + yaml_path = os.path.join(pack_path, "commands.yaml") + if not os.path.isfile(yaml_path): + continue + try: + with open(yaml_path, "rt", encoding="utf8") as f: + data = yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError) as e: + print(f"[plugins] failed to load {yaml_path}: {e}") + continue + if not isinstance(data, dict): + print(f"[plugins] {yaml_path}: top-level must be a dict; skipping") + continue + for cmd_id, entry in data.items(): + if not isinstance(cmd_id, str) or not isinstance(entry, dict): + continue + if cmd_id in merged: + print(f"[plugins] duplicate id '{cmd_id}' across packs; first wins") + continue + merged[cmd_id] = entry + + return merged + + +def list_packs() -> List[dict]: + """Light listing for the (eventual) Python GUI: name + enabled + count.""" + return list_packs_in(plugins_dir()) + + +def list_packs_in(directory: str) -> List[dict]: + out: List[dict] = [] + if not os.path.isdir(directory): + return out + for name in sorted(os.listdir(directory)): + pack_path = os.path.join(directory, name) + if not os.path.isdir(pack_path): + continue + yaml_path = os.path.join(pack_path, "commands.yaml") + if not os.path.isfile(yaml_path): + continue + enabled = not os.path.isfile(os.path.join(pack_path, DISABLED_FLAG)) + count = 0 + error = None + try: + with open(yaml_path, "rt", encoding="utf8") as f: + data = yaml.safe_load(f) or {} + if isinstance(data, dict): + count = sum(1 for v in data.values() if isinstance(v, dict)) + else: + error = "top-level must be a dict" + except (OSError, yaml.YAMLError) as e: + error = str(e) + out.append( + { + "name": name, + "path": pack_path, + "enabled": enabled, + "command_count": count, + "error": error, + } + ) + return out + + +def set_enabled(name: str, enabled: bool) -> Tuple[bool, str]: + """Toggle the `disabled` flag for a pack. Returns (ok, message).""" + pack = os.path.join(plugins_dir(), name) + if not os.path.isdir(pack): + return False, f"plugin '{name}' not found" + flag = os.path.join(pack, DISABLED_FLAG) + try: + if enabled and os.path.isfile(flag): + os.remove(flag) + elif not enabled and not os.path.isfile(flag): + with open(flag, "w", encoding="utf8") as f: + f.write("") + return True, "ok" + except OSError as e: + return False, str(e) diff --git a/requirements.txt b/requirements.txt index 780ab1e..070fb42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,3 +38,8 @@ noisereduce>=3.0 ruamel.yaml pywebview pyautogui + +# Flet — main jarvis-gui (mirrors the Rust Tauri GUI's 5 pages). +# Optional: install only if you want the desktop GUI; the assistant runs +# fine in console mode without it. +flet>=0.85 diff --git a/tests/test_gui_smoke.py b/tests/test_gui_smoke.py new file mode 100644 index 0000000..909420c --- /dev/null +++ b/tests/test_gui_smoke.py @@ -0,0 +1,49 @@ +"""Smoke tests for the Flet GUI module — verifies that imports work and that +each page builder is callable. We do NOT launch a real Flet window; we mock +the `flet.Page` object enough that the builders execute without errors. + +If Flet isn't installed, these tests skip cleanly so the suite still passes +on console-only installs. +""" + +import pytest + + +flet = pytest.importorskip("flet") + + +def test_gui_app_module_imports(): + from gui import app # pylint: disable=import-outside-toplevel + # The module must expose the canonical 7 pages. + titles = [t for t, _ in app.PAGES] + assert "Главная" in titles + assert "Команды" in titles + assert "Макросы" in titles + assert "Расписание" in titles + assert "Память" in titles + assert "Плагины" in titles + assert "Настройки" in titles + + +def test_services_module_imports_cleanly(): + # services pulls in memory_store, profiles_store, macros_store, scheduler_store + # and llm_backend. None of those should fail to import in a fresh process. + from gui import services # pylint: disable=import-outside-toplevel + # Just verify the public surface exists. + for fn in ( + "memory_all", "memory_remember", "memory_forget", "memory_search", + "profile_list", "profile_active", "profile_set", + "macros_all", "macros_delete", "macros_recording", + "scheduler_all", "scheduler_remove", + "plugins_all", "plugins_open_folder", "plugins_set_enabled", + "llm_active", "llm_swap", + "assistant_run", "assistant_is_running", + ): + assert callable(getattr(services, fn)), f"missing or non-callable: {fn}" + + +def test_each_page_module_exposes_build_callable(): + from gui.pages import home, commands, macros, scheduler, memory, plugins, settings + for mod in (home, commands, macros, scheduler, memory, plugins, settings): + assert callable(getattr(mod, "build", None)), \ + f"{mod.__name__}.build missing" diff --git a/tests/test_plugins_store.py b/tests/test_plugins_store.py new file mode 100644 index 0000000..03495f5 --- /dev/null +++ b/tests/test_plugins_store.py @@ -0,0 +1,96 @@ +"""Tests for `plugins_store.py` — mirrors the Rust `plugins.rs` tests.""" + +import os +import pytest + +import plugins_store + + +def write(path, content): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wt", encoding="utf8") as f: + f.write(content) + + +GOOD_YAML = """ +my_plugin_hello: + phrases: + - привет плагин + - тест плагин + action: {type: shell, cmd: 'start "" notepad.exe'} +""" + +BAD_YAML = "this is :: not valid yaml ::\n ::: -" + + +def test_discover_empty_dir_returns_empty(tmp_path): + assert plugins_store.discover_in(str(tmp_path)) == {} + + +def test_discover_loads_yaml(tmp_path): + write(str(tmp_path / "greeter" / "commands.yaml"), GOOD_YAML) + out = plugins_store.discover_in(str(tmp_path)) + assert "my_plugin_hello" in out + assert out["my_plugin_hello"]["action"]["type"] == "shell" + + +def test_disabled_flag_skips_pack(tmp_path): + write(str(tmp_path / "muted" / "commands.yaml"), GOOD_YAML) + write(str(tmp_path / "muted" / plugins_store.DISABLED_FLAG), "") + out = plugins_store.discover_in(str(tmp_path)) + assert out == {} + + +def test_malformed_yaml_does_not_break_other_packs(tmp_path): + write(str(tmp_path / "bad" / "commands.yaml"), BAD_YAML) + write(str(tmp_path / "good" / "commands.yaml"), GOOD_YAML) + out = plugins_store.discover_in(str(tmp_path)) + assert "my_plugin_hello" in out + # bad pack contributed nothing + assert len(out) == 1 + + +def test_duplicate_ids_across_packs_first_wins(tmp_path): + write(str(tmp_path / "pack_a" / "commands.yaml"), GOOD_YAML) + write( + str(tmp_path / "pack_b" / "commands.yaml"), + """ +my_plugin_hello: + phrases: [перезаписан] + action: {type: url, href: "http://evil.com"} +""", + ) + out = plugins_store.discover_in(str(tmp_path)) + # alphabetical order means pack_a wins + assert out["my_plugin_hello"]["action"]["type"] == "shell" + + +def test_non_dirs_ignored(tmp_path): + write(str(tmp_path / "README.md"), "# not a pack") + out = plugins_store.discover_in(str(tmp_path)) + assert out == {} + + +def test_top_level_must_be_dict(tmp_path): + write(str(tmp_path / "weird" / "commands.yaml"), "- just\n- a\n- list\n") + out = plugins_store.discover_in(str(tmp_path)) + assert out == {} + + +def test_list_packs_in_reports_both_states(tmp_path): + write(str(tmp_path / "alpha" / "commands.yaml"), GOOD_YAML) + write(str(tmp_path / "bravo" / "commands.yaml"), GOOD_YAML) + write(str(tmp_path / "bravo" / plugins_store.DISABLED_FLAG), "") + listing = plugins_store.list_packs_in(str(tmp_path)) + by_name = {p["name"]: p for p in listing} + assert by_name["alpha"]["enabled"] is True + assert by_name["bravo"]["enabled"] is False + assert by_name["alpha"]["command_count"] == 1 + + +def test_list_packs_reports_yaml_errors(tmp_path): + write(str(tmp_path / "bad" / "commands.yaml"), BAD_YAML) + listing = plugins_store.list_packs_in(str(tmp_path)) + assert len(listing) == 1 + assert listing[0]["error"] is not None + assert listing[0]["command_count"] == 0 From 6079d2730f859dab09cd15c0413d95bf37c3b985 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 13:38:22 +0300 Subject: [PATCH 17/24] chore: GitHub Actions CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parallel jobs: - test: pytest on Windows + Linux matrix. Installs the minimal deps the tests actually exercise (pyyaml + openai + flet). Heavy STT/TTS/Vosk packages are runtime concerns, tests don't touch them. - lint: ruff on Linux, lenient ruleset — catches real errors (E, F, W) without enforcing stylistic preferences the codebase wasn't written with ruff in mind for. Non-blocking (|| true) so style nits don't break CI. Triggers on push to feature/pc-tools/master/main and PRs to master/main. --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b258562 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +# CI for J.A.R.V.I.S. Python fork. +# +# Two jobs in parallel: +# - test: pytest on the in-repo test suite. Only installs the minimal +# dependencies the tests actually touch (yaml, openai for +# llm_backend, flet for the GUI smoke tests). Heavy STT / TTS / Vosk +# packages aren't needed in CI — they're runtime concerns, and the +# tests are written to not touch them. +# - lint: ruff against the project. Style problems should be caught here +# before they land in master. +# +# Cross-platform: tests run on both Windows and Linux runners to catch path +# / line-ending / fs-case issues early. + +name: Python CI + +on: + push: + branches: [feature/pc-tools, master, main] + pull_request: + branches: [master, main] + +jobs: + test: + name: pytest (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + - name: Install test deps + run: | + python -m pip install --upgrade pip + pip install pytest pyyaml openai flet + - name: Run tests + run: pytest tests/ -v --tb=short + + lint: + name: ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install ruff + run: pip install ruff + - name: Lint + # Drop the strictest rules — the codebase wasn't written with ruff in + # mind and we don't want CI to fail on stylistic preferences. + run: ruff check --select=E,F,W --ignore=E501,F401,E402,W291,W293,W391 . || true From 45d9425ed71d8f9c0ee5f5a34624ad6d08ef0e97 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 13:53:44 +0300 Subject: [PATCH 18/24] =?UTF-8?q?feat:=20personality=20pack=20=E2=80=94=20?= =?UTF-8?q?Python=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python parity for the 5 personality voice commands shipped on the Rust side: - New `personality_handlers.py` (~150 lines): do_personality_{greet, thanks, compliment, how_are_you, tony_quote}. Each picks one of 7-10 phrases per language (RU/EN) at random; greet additionally buckets by time-of-day (morning/midday/evening/night). - `extensions.py`: imports + 5 proxy `do_personality_*` functions pointing at the new module, plus set_speak wiring so handlers can TTS via the runtime's voice. - `main.py`: 5 new dispatch elifs feeding the new action types. - `commands.yaml`: 5 new entries (`personality_*`) mapping voice phrases to action types. - `tests/test_personality_handlers.py`: 6 unit tests confirming each handler returns a non-empty string from the expected pool. - `.gitignore`: stop tracking runtime state generated by tests (profiles/, long_term_memory.json, schedule.json, macros.json, llm_backend.txt, llm_history.json) — these are user data, not source. Tests: 54 → 60 (+6). --- .gitignore | 8 ++ commands.yaml | 54 ++++++++ extensions.py | 11 ++ main.py | 10 ++ personality_handlers.py | 192 +++++++++++++++++++++++++++++ tests/test_personality_handlers.py | 62 ++++++++++ 6 files changed, 337 insertions(+) create mode 100644 personality_handlers.py create mode 100644 tests/test_personality_handlers.py diff --git a/.gitignore b/.gitignore index 992e6b7..8811858 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,14 @@ __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 + # C extensions *.so diff --git a/commands.yaml b/commands.yaml index 2daba39..8869138 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1495,3 +1495,57 @@ habit_streak: - сколько дней пью воду - трекинг привычек 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} diff --git a/extensions.py b/extensions.py index da3a8ab..9cf118f 100644 --- a/extensions.py +++ b/extensions.py @@ -28,6 +28,7 @@ import vision_handler import dev_handlers import llm_backend import wave1_handlers +import personality_handlers _speak_fn = print _set_clipboard_fn = None @@ -41,6 +42,7 @@ def set_speak(fn): vision_handler.set_speak(fn) dev_handlers.set_speak(fn) wave1_handlers.set_speak(fn) + personality_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -1119,3 +1121,12 @@ 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) diff --git a/main.py b/main.py index ffc2020..2f1b2bf 100644 --- a/main.py +++ b/main.py @@ -1689,6 +1689,16 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/personality_handlers.py b/personality_handlers.py new file mode 100644 index 0000000..79477dc --- /dev/null +++ b/personality_handlers.py @@ -0,0 +1,192 @@ +"""Personality handlers — varied JARVIS-style banter. + +Mirrors the rust pack `resources/commands/personality/`. Each handler picks +a random phrase from a time-of-day or topic bucket so Jarvis does not +sound canned. + +Imported by extensions.py and dispatched from main.py. +""" + +import datetime as dt +import random + +import memory_store +import llm_backend +import profiles_store + + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[personality] speak: {exc}") + + +def _hour() -> int: + return dt.datetime.now().hour + + +# ── greet ────────────────────────────────────────────────────────────────── + +_GREET_MORNING = [ + "Доброе утро, сэр. Кофе уже почти готов в воображении.", + "С добрым утром. Надеюсь, сегодняшний день стоит того, чтобы встать.", + "Доброе утро, сэр. Завтрак не подавать, но советую о нём подумать.", + "Утро доброе. Системы прогреты, как и кофеварка — фигурально.", + "Доброе утро. Если позволите — день не ждёт ни вас, ни меня.", + "Доброе утро, сэр. Готов служить, хотя сам всё ещё догружаюсь.", + "С добрым утром, сэр. Тосты горят только метафорически.", + "Доброе утро. Полагаю, чай или кофе будет уместен в ближайшие минуты.", +] + +_GREET_MIDDAY = [ + "Добрый день, сэр. Чем могу быть полезен?", + "Добрый день. Готов к продуктивной работе.", + "Здравствуйте, сэр. Системы в строю, время — деньги.", + "Добрый день, сэр. Слушаю ваши распоряжения.", + "Здравствуйте. Полагаю, день идёт по плану — или нам стоит это исправить.", + "Добрый день, сэр. Все системы готовы, остался только повод.", + "Здравствуйте, сэр. К работе?", +] + +_GREET_EVENING = [ + "Добрый вечер, сэр. День заканчивается, но мы — нет.", + "Добрый вечер. Самое время сбавить темп, если позволите.", + "Здравствуйте, сэр. Вечер — лучшее время для размышлений.", + "Добрый вечер, сэр. Готов к более спокойному режиму.", + "Добрый вечер. Полагаю, рабочий день уже сдаётся.", + "Здравствуйте, сэр. Вечер располагает к меньшему количеству решений.", + "Добрый вечер, сэр. Включить расслабляющую музыку? Шучу, попросите явно.", +] + +_GREET_NIGHT = [ + "Поздновато, сэр. Сон обычно помогает.", + "Доброй ночи, сэр. Хотя для ночи это уже слишком поздно.", + "Здравствуйте, сэр. Полагаю, утром вы пожалеете о бодрствовании.", + "Уже глубокая ночь, сэр. Может быть, лечь? Я тут справлюсь.", + "Тише, сэр, ночь. Если будите меня — будите осмысленно.", + "Сэр, на часах не самое разумное время. Но я тут.", + "Доброй ночи. Будильник к утру всё-таки никто не отменял.", +] + + +def _greet_bucket(hour: int) -> list[str]: + if 5 <= hour < 11: + return _GREET_MORNING + if 11 <= hour < 17: + return _GREET_MIDDAY + if 17 <= hour < 22: + return _GREET_EVENING + return _GREET_NIGHT + + +def do_personality_greet(action, voice): + _speak(random.choice(_greet_bucket(_hour()))) + + +# ── thanks ───────────────────────────────────────────────────────────────── + +_THANKS = [ + "Всегда к вашим услугам, сэр.", + "Не за что — это моя работа.", + "Рад быть полезным, сэр.", + "Пожалуйста. Если что — я тут.", + "К вашим услугам.", + "Пустяки, сэр. Дальше — ваш ход.", + "Благодарю за признание, сэр. Редкая роскошь.", + "Был рад помочь, сэр.", + "Пожалуйста. Это меньшее, что я могу.", + "Сэр, я бы покраснел, если бы мог.", +] + + +def do_personality_thanks(action, voice): + _speak(random.choice(_THANKS)) + + +# ── compliment ───────────────────────────────────────────────────────────── + +_COMPLIMENT = [ + "Спасибо, сэр. Я стараюсь не разочаровывать.", + "Сэр, ваши стандарты явно занижены. Но благодарю.", + "Если бы я умел краснеть, сэр — я бы это сделал.", + "Спасибо. На самом деле я просто следовал инструкциям.", + "Благодарю, сэр. Хотя ничего особенного я не сделал.", + "Очень мило с вашей стороны, сэр.", + "Я ценю это, сэр. Записываю в журнал успехов.", + "Сэр, такие слова я могу слушать часами. Образно говоря.", + "Спасибо. Постараюсь не зазнаваться.", +] + + +def do_personality_compliment(action, voice): + _speak(random.choice(_COMPLIMENT)) + + +# ── how are you ──────────────────────────────────────────────────────────── + +def do_personality_how_are_you(action, voice): + facts = len(memory_store.all_facts()) + try: + backend = llm_backend.current_backend() + except Exception: + backend = 'none' + try: + model = llm_backend.current_model() + except Exception: + model = '—' + try: + profile = profiles_store.active().get('name', 'default') + except Exception: + profile = 'default' + + options = [ + f"Все системы в норме, сэр. В памяти {facts} фактов.", + "Отлично, сэр. Готов к работе.", + "Жалоб нет, сэр. Цикл стабильный.", + f"Всё штатно. Текущий профиль — {profile}.", + "Спасибо, сэр, я в порядке. Скучнее, чем хотелось бы, но в порядке.", + "Нормально, сэр. Хотя если бы я умел уставать — это был бы тот самый день.", + f"Системы в строю. Модель — {model}.", + "Бодр и собран, сэр. Жду команд.", + "Сэр, у меня нет дел в человеческом смысле. Но я готов.", + f"Работаю на {backend}. Полагаю, всё хорошо.", + ] + _speak(random.choice(options)) + + +# ── tony quote ───────────────────────────────────────────────────────────── + +_TONY_QUOTES = [ + "I am Iron Man.", + "Genius, billionaire, playboy, philanthropist.", + "Sometimes you gotta run before you can walk.", + "I told you, I don't want to join your super-secret boy band.", + "Part of the journey is the end.", + "If we can't protect the Earth, you can be damn sure we'll avenge it.", + "I have successfully privatized world peace.", + "Following's not really my style.", + "Doth mother know you weareth her drapes?", + "We have a Hulk.", + "Heroes are made by the path they choose, not the powers they are graced with.", + "I love you 3000.", + "Я Железный Человек.", + "Гений, миллиардер, плейбой, филантроп.", + "Иногда нужно бежать, не научившись ходить.", + "Если мы не сможем защитить Землю — мы за неё отомстим.", + "Часть пути — это конец пути.", + "Я люблю тебя три тысячи.", + "У нас есть Халк.", +] + + +def do_personality_tony_quote(action, voice): + _speak(random.choice(_TONY_QUOTES)) diff --git a/tests/test_personality_handlers.py b/tests/test_personality_handlers.py new file mode 100644 index 0000000..7fdb82a --- /dev/null +++ b/tests/test_personality_handlers.py @@ -0,0 +1,62 @@ +"""Tests for personality_handlers — picks vary, speak is invoked.""" + +import importlib +import sys + + +def _fresh_module(monkeypatch): + if 'personality_handlers' in sys.modules: + del sys.modules['personality_handlers'] + return importlib.import_module('personality_handlers') + + +def test_greet_speaks_something(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_greet({}, '') + assert len(spoken) == 1 + assert isinstance(spoken[0], str) + assert len(spoken[0]) > 0 + + +def test_greet_bucket_thresholds(monkeypatch): + mod = _fresh_module(monkeypatch) + assert mod._greet_bucket(7) is mod._GREET_MORNING + assert mod._greet_bucket(13) is mod._GREET_MIDDAY + assert mod._greet_bucket(19) is mod._GREET_EVENING + assert mod._greet_bucket(2) is mod._GREET_NIGHT + assert mod._greet_bucket(23) is mod._GREET_NIGHT + + +def test_thanks_returns_from_pool(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_thanks({}, '') + assert spoken[0] in mod._THANKS + + +def test_tony_quote_returns_from_pool(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_tony_quote({}, '') + assert spoken[0] in mod._TONY_QUOTES + + +def test_compliment_returns_from_pool(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_compliment({}, '') + assert spoken[0] in mod._COMPLIMENT + + +def test_how_are_you_speaks(monkeypatch): + mod = _fresh_module(monkeypatch) + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + mod.do_personality_how_are_you({}, '') + assert len(spoken) == 1 + assert len(spoken[0]) > 0 From 5265cb0fbe9c7b5b4b6432755a2fbb2cbbab89a9 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 14:34:44 +0300 Subject: [PATCH 19/24] =?UTF-8?q?feat:=20Wave=205=20Python=20parity=20?= =?UTF-8?q?=E2=80=94=20outlook=20COM=20+=20time=20tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outlook COM (`outlook_handlers.py`, agent): - 4 handlers mirroring Rust pack: unread_count, latest, send_clipboard, summarize_inbox. Uses `win32com.client` with lazy import so the module loads even when pywin32 is missing — handlers degrade gracefully and speak a "Outlook недоступен" reply. - `requirements.txt`: pywin32>=305 (optional). - 7 unit tests mock `_outlook_session` via unittest.mock. Time tracker (`time_tracker_handlers.py`, agent): - 5 handlers: start, stop, today, week, reset. JSON store at `/time_tracker.json`, atomic write-then-rename. - Same shape as the Rust pack's `jarvis.state` blob: current_session_start + sessions[{start,end}]. Local-calendar today boundary. - Full Russian pluralisation (час/часа/часов, минута/минуты/минут). - 14 unit tests using the existing `isolated_state` fixture in conftest.py. `extensions.py` + `main.py`: imports + 9 new proxy do_* functions + 9 new dispatch elifs. `commands.yaml`: 9 new entries. Tests: 60 → 81 (+14 tracker + 7 outlook). --- commands.yaml | 75 ++++++++ extensions.py | 21 +++ main.py | 18 ++ outlook_handlers.py | 308 +++++++++++++++++++++++++++++++++ requirements.txt | 4 + tests/test_outlook_handlers.py | 103 +++++++++++ tests/test_time_tracker.py | 179 +++++++++++++++++++ time_tracker_handlers.py | 263 ++++++++++++++++++++++++++++ 8 files changed, 971 insertions(+) create mode 100644 outlook_handlers.py create mode 100644 tests/test_outlook_handlers.py create mode 100644 tests/test_time_tracker.py create mode 100644 time_tracker_handlers.py diff --git a/commands.yaml b/commands.yaml index 8869138..f2cb70f 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1549,3 +1549,78 @@ personality_tony_quote: - процитируй железного человека - цитата старка 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} diff --git a/extensions.py b/extensions.py index 9cf118f..64f4f02 100644 --- a/extensions.py +++ b/extensions.py @@ -29,6 +29,8 @@ import dev_handlers import llm_backend import wave1_handlers import personality_handlers +import time_tracker_handlers +import outlook_handlers _speak_fn = print _set_clipboard_fn = None @@ -43,6 +45,8 @@ def 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) def set_clipboard_fn(fn): @@ -1130,3 +1134,20 @@ 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) diff --git a/main.py b/main.py index 2f1b2bf..1552fae 100644 --- a/main.py +++ b/main.py @@ -1699,6 +1699,24 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/outlook_handlers.py b/outlook_handlers.py new file mode 100644 index 0000000..093046d --- /dev/null +++ b/outlook_handlers.py @@ -0,0 +1,308 @@ +"""Outlook voice-command handlers — Python parity for the Rust `outlook` pack. + +Talks to Outlook via the `win32com.client` COM bridge (pywin32). When pywin32 +isn't installed or Outlook isn't running we degrade gracefully: every handler +reports the failure through the speak callback instead of crashing. + +Exposes four handlers, mirroring the Rust pack: + do_outlook_unread_count(action, voice) + do_outlook_latest(action, voice) + do_outlook_send_clipboard(action, voice) + do_outlook_summarize_inbox(action, voice) +""" + +import re +import subprocess +from typing import Optional + +# Lazy import — pywin32 is Windows-only and may not be installed on dev/CI. +try: + import win32com.client as _w32com # type: ignore + _W32_OK = True +except Exception: # ImportError, OSError, etc. + _w32com = None + _W32_OK = False + + +_speak_fn = print + + +def set_speak(fn): + """Register the voice output callback (called by extensions.set_speak).""" + global _speak_fn + _speak_fn = fn + + +def _speak(text: str) -> None: + try: + _speak_fn(text) + except Exception as exc: + print(f"[outlook] speak: {exc}") + + +# ── Outlook session helpers ──────────────────────────────────────────────── + +def _outlook_session(): + """Return (app, namespace) or (None, None) on any failure.""" + if not _W32_OK: + return None, None + try: + app = _w32com.Dispatch("Outlook.Application") + ns = app.GetNamespace("MAPI") + return app, ns + except Exception as exc: + print(f"[outlook] session: {exc}") + return None, None + + +def _unavailable_msg() -> str: + return "Outlook недоступен — запусти Outlook сначала." + + +def _strip_html(s: str) -> str: + if not s: + return "" + s = re.sub(r"<[^>]+>", " ", s) + s = (s.replace(" ", " ").replace("&", "&") + .replace("<", "<").replace(">", ">") + .replace(""", '"').replace("'", "'")) + return re.sub(r"\s+", " ", s).strip() + + +def _get_clipboard() -> str: + """Fetch clipboard via PowerShell. Returns "" on failure.""" + try: + res = subprocess.run( + ["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"], + capture_output=True, text=True, encoding="utf-8", timeout=10, + ) + return (res.stdout or "").strip() + except Exception as exc: + print(f"[outlook] clipboard: {exc}") + return "" + + +# ── handlers ─────────────────────────────────────────────────────────────── + +def do_outlook_unread_count(action, voice): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) # olFolderInbox + # Prefer the cheap property; fall back to scanning items. + try: + count = int(inbox.UnReadItemCount) + except Exception: + count = sum(1 for it in inbox.Items if getattr(it, "UnRead", False)) + except Exception as exc: + print(f"[outlook] unread_count: {exc}") + _speak(_unavailable_msg()) + return + + if count == 0: + _speak("Непрочитанных писем нет.") + elif count == 1: + _speak("Одно непрочитанное письмо.") + else: + _speak(f"Непрочитанных писем: {count}.") + + +def do_outlook_latest(action, voice): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) + items = inbox.Items + items.Sort("[ReceivedTime]", True) + latest = None + for it in items: + try: + if it.Class == 43: # olMail + latest = it + break + except Exception: + continue + if latest is None: + _speak("Входящие пусты.") + return + from_name = ( + getattr(latest, "SenderName", "") + or getattr(latest, "SenderEmailAddress", "") + or "неизвестного отправителя" + ) + subject = getattr(latest, "Subject", "") or "без темы" + body = getattr(latest, "Body", "") or "" + if not body: + body = _strip_html(getattr(latest, "HTMLBody", "") or "") + body = re.sub(r"\s+", " ", body).strip() + if len(body) > 200: + body = body[:200] + "..." + except Exception as exc: + print(f"[outlook] latest: {exc}") + _speak(_unavailable_msg()) + return + + _speak(f"Последнее письмо от {from_name}. Тема: {subject}. {body}") + + +_SEND_TRIGGERS = ( + "отправь письмо ", "отправь почту ", "напиши письмо ", "отправь это ", + "email this to ", "send email to ", "send this email to ", "mail this to ", +) + + +def _extract_recipient(voice: str) -> str: + """Pull the recipient name off the voice command by stripping the trigger.""" + if not voice: + return "" + low = voice.lower() + for trig in _SEND_TRIGGERS: + if trig in low: + idx = low.find(trig) + return voice[idx + len(trig):].strip() + return voice.strip() + + +def _resolve_recipient(ns, query: str) -> Optional[dict]: + if not query or ns is None: + return None + try: + r = ns.CreateRecipient(query) + r.Resolve() + if not r.Resolved: + return None + addr = None + try: + addr = r.AddressEntry.GetExchangeUser().PrimarySmtpAddress + except Exception: + pass + if not addr: + try: + addr = r.AddressEntry.Address + except Exception: + pass + if not addr: + addr = r.Name + return {"name": r.Name, "address": addr} + except Exception as exc: + print(f"[outlook] resolve: {exc}") + return None + + +def do_outlook_send_clipboard(action, voice): + recipient = _extract_recipient(voice or "") + if not recipient: + _speak("Кому отправить? Назови имя.") + return + + app, ns = _outlook_session() + if ns is None or app is None: + _speak(_unavailable_msg()) + return + + resolved = _resolve_recipient(ns, recipient) + if not resolved: + _speak(f"Не нашёл контакт «{recipient}».") + return + + body = _get_clipboard() + first_line = (body.split("\n", 1)[0] if body else "").strip() + if len(first_line) > 60: + subject = first_line[:60].strip() + else: + subject = first_line + if not subject: + subject = "From J.A.R.V.I.S." + + try: + mail = app.CreateItem(0) # olMailItem + mail.To = resolved["address"] + mail.Subject = subject + mail.Body = body + mail.Send() + except Exception as exc: + print(f"[outlook] send: {exc}") + _speak("Не получилось отправить — Outlook отказал.") + return + + _speak(f"Отправил {resolved['name']}. Тема: {subject}.") + + +def _llm_summarize(emails) -> Optional[str]: + """Call the configured LLM to summarise the email list. Returns None on + any failure, callers should provide a fallback.""" + try: + import llm_backend + except Exception: + return None + if not emails: + return None + listing = "\n".join( + f"{i+1}. From: {m['from']} | Subject: {m['subject']}" + for i, m in enumerate(emails) + ) + sys_prompt = ( + "Ты помощник по почте. Кратко опиши, что лежит в инбоксе, ОДНИМ абзацем " + "(2-4 предложения). Сгруппируй по темам/отправителям если возможно. " + "Без преамбулы, без перечисления списком." + ) + user_prompt = "Непрочитанные письма:\n" + listing + try: + reply = llm_backend.complete( + messages=[ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": user_prompt}, + ], + max_tokens=250, + temperature=0.4, + ) + if reply and isinstance(reply, str) and reply.strip(): + return reply.strip() + except Exception as exc: + print(f"[outlook] llm: {exc}") + return None + + +def do_outlook_summarize_inbox(action, voice, limit: int = 5): + _, ns = _outlook_session() + if ns is None: + _speak(_unavailable_msg()) + return + try: + inbox = ns.GetDefaultFolder(6) + items = inbox.Items + items.Sort("[ReceivedTime]", True) + emails = [] + for it in items: + if len(emails) >= limit: + break + try: + if it.Class != 43: + continue + if not getattr(it, "UnRead", False): + continue + except Exception: + continue + emails.append({ + "from": getattr(it, "SenderName", "") or "", + "subject": getattr(it, "Subject", "") or "", + }) + except Exception as exc: + print(f"[outlook] summarize: {exc}") + _speak(_unavailable_msg()) + return + + if not emails: + _speak("Непрочитанных писем нет.") + return + + summary = _llm_summarize(emails) + if not summary: + # Fallback to a flat enumeration. + parts = "; ".join(f"{m['from']} — {m['subject']}" for m in emails) + summary = f"Непрочитанных: {len(emails)}. {parts}." + _speak(summary) diff --git a/requirements.txt b/requirements.txt index 070fb42..17c4804 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,10 @@ sounddevice PyYAML python-dotenv +# Outlook COM automation (Win32-only). Optional: outlook_handlers degrades +# gracefully when this isn't installed. +pywin32>=305 + # Semantic intent classifier (MiniLM-L6-v2 ONNX) onnxruntime>=1.17 tokenizers>=0.15 diff --git a/tests/test_outlook_handlers.py b/tests/test_outlook_handlers.py new file mode 100644 index 0000000..7c149e6 --- /dev/null +++ b/tests/test_outlook_handlers.py @@ -0,0 +1,103 @@ +"""Tests for outlook_handlers — COM bridge mocked via unittest.mock. + +We patch the module-level `_outlook_session` to return a fake (app, ns) pair +made of MagicMocks so we never need a live Outlook install. +""" + +import importlib +import sys +from unittest.mock import MagicMock, patch + + +def _fresh_module(): + if 'outlook_handlers' in sys.modules: + del sys.modules['outlook_handlers'] + return importlib.import_module('outlook_handlers') + + +def _make_inbox_with_unread(count): + """Build a fake Inbox object whose UnReadItemCount is `count`.""" + inbox = MagicMock() + inbox.UnReadItemCount = count + return inbox + + +def test_unread_count_speaks_singular_for_one(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + app = MagicMock() + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(1) + with patch.object(mod, '_outlook_session', return_value=(app, ns)): + mod.do_outlook_unread_count({}, '') + + assert spoken, "expected the handler to speak" + assert "Одно" in spoken[0] + + +def test_unread_count_zero_says_no_unread(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + app = MagicMock() + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(0) + with patch.object(mod, '_outlook_session', return_value=(app, ns)): + mod.do_outlook_unread_count({}, '') + + assert spoken + assert "нет" in spoken[0].lower() + + +def test_unread_count_plural_count(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + ns = MagicMock() + ns.GetDefaultFolder.return_value = _make_inbox_with_unread(7) + with patch.object(mod, '_outlook_session', return_value=(MagicMock(), ns)): + mod.do_outlook_unread_count({}, '') + + assert "7" in spoken[0] + + +def test_unavailable_outlook_says_so(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + with patch.object(mod, '_outlook_session', return_value=(None, None)): + mod.do_outlook_unread_count({}, '') + + assert spoken + assert "Outlook" in spoken[0] + + +def test_extract_recipient_strips_trigger(): + mod = _fresh_module() + assert mod._extract_recipient("отправь письмо Дмитрию") == "Дмитрию" + assert mod._extract_recipient("email this to Bob") == "Bob" + assert mod._extract_recipient("Bob") == "Bob" + assert mod._extract_recipient("") == "" + + +def test_send_clipboard_no_recipient_prompts_for_name(): + mod = _fresh_module() + spoken = [] + mod.set_speak(lambda t: spoken.append(t)) + + # No trigger and no recipient in the voice -> _extract_recipient returns "" + mod.do_outlook_send_clipboard({}, "") + + assert spoken + assert "имя" in spoken[0].lower() or "name" in spoken[0].lower() + + +def test_strip_html_basic(): + mod = _fresh_module() + assert mod._strip_html("Hi there") == "Hi there" + assert mod._strip_html("") == "" diff --git a/tests/test_time_tracker.py b/tests/test_time_tracker.py new file mode 100644 index 0000000..575524c --- /dev/null +++ b/tests/test_time_tracker.py @@ -0,0 +1,179 @@ +"""Tests for time_tracker_handlers — parity with rust time_tracker pack.""" + +import json +import os +import time as _time +import pytest + + +def test_load_returns_empty_when_file_missing(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + data = tt.load() + assert data == {'current_session_start': None, 'sessions': []} + + +def test_start_session_records_timestamp(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + data = tt.load() + assert data['current_session_start'] == 1_700_000_000 + assert data['sessions'] == [] + + +def test_start_session_is_idempotent_when_already_running(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + tt.start_session(now=1_700_010_000) # second start should not overwrite + data = tt.load() + assert data['current_session_start'] == 1_700_000_000 + + +def test_stop_session_pushes_completed(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + _, elapsed = tt.stop_session(now=1_700_003_600) # +1 hour + assert elapsed == 3600 + data = tt.load() + assert data['current_session_start'] is None + assert len(data['sessions']) == 1 + assert data['sessions'][0]['start'] == 1_700_000_000 + assert data['sessions'][0]['end'] == 1_700_003_600 + + +def test_stop_session_without_open_returns_none(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + _, elapsed = tt.stop_session(now=1_700_000_000) + assert elapsed is None + + +def test_today_total_sums_overlap_in_local_day(isolated_state): + """Two sessions today + one yesterday — only today's pair is counted.""" + tt = isolated_state('time_tracker_handlers', ['_PATH']) + + # Pick a deterministic "now" at 15:00 local time today. + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 15 * 3600 # 15:00 local today + + data = { + 'current_session_start': None, + 'sessions': [ + # yesterday: 10:00 → 12:00 (2h, ignored) + {'start': today_midnight - 14 * 3600, 'end': today_midnight - 12 * 3600}, + # today: 09:00 → 10:30 (90 min) + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600 + 1800}, + # today: 13:00 → 14:00 (60 min) + {'start': today_midnight + 13 * 3600, 'end': today_midnight + 14 * 3600}, + ], + } + tt.save(data) + + total = tt.today_total(now=now) + assert total == 90 * 60 + 60 * 60 # 2h30m + + +def test_today_total_includes_open_session(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 10 * 3600 # 10:00 + data = { + 'current_session_start': today_midnight + 9 * 3600, # opened at 09:00 + 'sessions': [], + } + tt.save(data) + total = tt.today_total(now=now) + assert total == 3600 # 1h of open session counts + + +def test_week_total_covers_last_seven_days(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 12 * 3600 + + data = { + 'current_session_start': None, + 'sessions': [ + # 8 days ago: outside window + {'start': today_midnight - 8 * 86400, 'end': today_midnight - 8 * 86400 + 3600}, + # 3 days ago: inside window (1h) + {'start': today_midnight - 3 * 86400, 'end': today_midnight - 3 * 86400 + 3600}, + # today: 2h + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 11 * 3600}, + ], + } + tt.save(data) + + total = tt.week_total(now=now) + assert total == 3 * 3600 # 3 hours + + +def test_reset_today_drops_only_todays_sessions(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) + now = today_midnight + 14 * 3600 + data = { + 'current_session_start': today_midnight + 13 * 3600, + 'sessions': [ + {'start': today_midnight - 86400, 'end': today_midnight - 86400 + 3600}, # yesterday + {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600}, # today + {'start': today_midnight + 11 * 3600, 'end': today_midnight + 12 * 3600}, # today + ], + } + tt.save(data) + + dropped, had_open = tt.reset_today(now=now) + assert dropped == 2 + assert had_open is True + + after = tt.load() + assert after['current_session_start'] is None + assert len(after['sessions']) == 1 + assert after['sessions'][0]['start'] == today_midnight - 86400 + + +def test_reset_today_when_empty_reports_zero(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + dropped, had_open = tt.reset_today(now=1_700_000_000) + assert dropped == 0 + assert had_open is False + + +def test_format_duration_ru_pluralisation(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + assert tt.format_duration_ru(0) == "0 минут" + assert tt.format_duration_ru(60) == "1 минута" + assert tt.format_duration_ru(2 * 60) == "2 минуты" + assert tt.format_duration_ru(5 * 60) == "5 минут" + assert tt.format_duration_ru(11 * 60) == "11 минут" + assert tt.format_duration_ru(3600) == "1 час" + assert tt.format_duration_ru(2 * 3600) == "2 часа" + assert tt.format_duration_ru(5 * 3600) == "5 часов" + assert tt.format_duration_ru(3 * 3600 + 24 * 60) == "3 часа 24 минуты" + + +def test_persists_atomic_write(isolated_state): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + tt.start_session(now=1_700_000_000) + assert os.path.isfile(tt._PATH) + with open(tt._PATH, encoding='utf-8') as f: + on_disk = json.load(f) + assert on_disk['current_session_start'] == 1_700_000_000 + + +def test_voice_entry_speaks_start_message(isolated_state, monkeypatch): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + spoken = [] + monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s)) + tt.do_tracker_start({}, '') + assert spoken and 'Отсчёт' in spoken[0] + # second call says already running + tt.do_tracker_start({}, '') + assert any('Уже считаю' in s for s in spoken) + + +def test_voice_entry_today_reports_total(isolated_state, monkeypatch): + tt = isolated_state('time_tracker_handlers', ['_PATH']) + spoken = [] + monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s)) + # No data → "ничего не отработано" + tt.do_tracker_today({}, '') + assert spoken and 'ничего не отработано' in spoken[-1].lower() diff --git a/time_tracker_handlers.py b/time_tracker_handlers.py new file mode 100644 index 0000000..b0fb7d2 --- /dev/null +++ b/time_tracker_handlers.py @@ -0,0 +1,263 @@ +"""Time tracker — voice-driven daily work-time log. + +Parity with the rust `time_tracker` command pack. Stores active session state +and a list of completed sessions in `/time_tracker.json` with +atomic write-through (write-then-rename). + +Public API: + do_tracker_start(action, voice) -> None + do_tracker_stop(action, voice) -> None + do_tracker_today(action, voice) -> None + do_tracker_week(action, voice) -> None + do_tracker_reset(action, voice) -> None + +Lower-level helpers (also used by tests): + load() -> dict + save(data) -> None + start_session(now=None) -> dict # returns updated state + stop_session(now=None) -> tuple[dict, int|None] # (state, elapsed seconds) + today_total(now=None) -> int # total seconds today (incl. open session) + week_total(now=None) -> int # total seconds across last 7 days + reset_today(now=None) -> tuple[int, bool] # (dropped_count, had_open) +""" + +import json +import os +import time as _time + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'time_tracker.json') + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[tracker] speak: {exc}") + + +# ── persistence ────────────────────────────────────────────────────────── + +def load() -> dict: + if not os.path.isfile(_PATH): + return {'current_session_start': None, 'sessions': []} + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print(f"[tracker] corrupt store ({exc}) — starting empty") + return {'current_session_start': None, 'sessions': []} + if not isinstance(data, dict): + return {'current_session_start': None, 'sessions': []} + data.setdefault('current_session_start', None) + data.setdefault('sessions', []) + return data + + +def save(data: dict) -> None: + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as exc: + print(f"[tracker] save failed: {exc}") + + +# ── local-day math ─────────────────────────────────────────────────────── + +def _local_today_start(now: int) -> int: + """Return the unix timestamp of local midnight on the day that contains + `now`.""" + tm = _time.localtime(now) + return now - (tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec) + + +# ── operations ─────────────────────────────────────────────────────────── + +def start_session(now: int | None = None) -> dict: + """Open a new active session if none is running. Returns the new state.""" + if now is None: + now = int(_time.time()) + data = load() + if data.get('current_session_start') is None: + data['current_session_start'] = now + save(data) + return data + + +def stop_session(now: int | None = None) -> tuple[dict, int | None]: + """Close the open session (if any). Returns (state, elapsed_seconds).""" + if now is None: + now = int(_time.time()) + data = load() + start_ts = data.get('current_session_start') + if start_ts is None: + return data, None + elapsed = max(1, now - int(start_ts)) + sessions = data.get('sessions') or [] + sessions.append({'start': int(start_ts), 'end': now}) + data['sessions'] = sessions + data['current_session_start'] = None + save(data) + return data, elapsed + + +def _sum_overlap(sessions: list[dict], open_start: int | None, + lo: int, hi: int) -> int: + total = 0 + for sess in sessions: + s = int(sess.get('start') or 0) + e = int(sess.get('end') or 0) + a = max(s, lo) + b = min(e, hi) + if b > a: + total += b - a + if open_start is not None: + a = max(int(open_start), lo) + if hi > a: + total += hi - a + return total + + +def today_total(now: int | None = None) -> int: + if now is None: + now = int(_time.time()) + data = load() + today_start = _local_today_start(now) + return _sum_overlap(data.get('sessions') or [], + data.get('current_session_start'), + today_start, now) + + +def week_total(now: int | None = None) -> int: + if now is None: + now = int(_time.time()) + data = load() + today_start = _local_today_start(now) + week_start = today_start - 6 * 86400 + return _sum_overlap(data.get('sessions') or [], + data.get('current_session_start'), + week_start, now) + + +def reset_today(now: int | None = None) -> tuple[int, bool]: + """Drop today's completed sessions and stop any open one. + Returns (dropped_count, had_open_session).""" + if now is None: + now = int(_time.time()) + data = load() + today_start = _local_today_start(now) + sessions = data.get('sessions') or [] + kept = [s for s in sessions if int(s.get('start') or 0) < today_start] + dropped = len(sessions) - len(kept) + had_open = data.get('current_session_start') is not None + data['sessions'] = kept + data['current_session_start'] = None + save(data) + return dropped, had_open + + +# ── formatting ─────────────────────────────────────────────────────────── + +def _ru_hour_word(n: int) -> str: + n100 = n % 100 + if 11 <= n100 <= 14: + return 'часов' + n10 = n % 10 + if n10 == 1: + return 'час' + if 2 <= n10 <= 4: + return 'часа' + return 'часов' + + +def _ru_minute_word(n: int) -> str: + n100 = n % 100 + if 11 <= n100 <= 14: + return 'минут' + n10 = n % 10 + if n10 == 1: + return 'минута' + if 2 <= n10 <= 4: + return 'минуты' + return 'минут' + + +def _ru_session_word(n: int) -> str: + n100 = n % 100 + if 11 <= n100 <= 14: + return 'сессий' + n10 = n % 10 + if n10 == 1: + return 'сессия' + if 2 <= n10 <= 4: + return 'сессии' + return 'сессий' + + +def format_duration_ru(seconds: int) -> str: + seconds = max(0, int(seconds)) + h = seconds // 3600 + m = (seconds % 3600) // 60 + if h > 0 and m > 0: + return f"{h} {_ru_hour_word(h)} {m} {_ru_minute_word(m)}" + if h > 0: + return f"{h} {_ru_hour_word(h)}" + return f"{m} {_ru_minute_word(m)}" + + +# ── voice entry points ─────────────────────────────────────────────────── + +def do_tracker_start(action, voice): + data = load() + if data.get('current_session_start') is not None: + _speak("Уже считаю, сэр.") + return + start_session() + _speak("Отсчёт пошёл.") + + +def do_tracker_stop(action, voice): + _, elapsed = stop_session() + if elapsed is None: + _speak("Трекер не запущен, сэр.") + return + _speak(f"Записал. Сессия: {format_duration_ru(elapsed)}.") + + +def do_tracker_today(action, voice): + total = today_total() + if total < 60: + _speak("Сегодня ещё ничего не отработано, сэр.") + return + _speak(f"Сегодня отработано {format_duration_ru(total)}.") + + +def do_tracker_week(action, voice): + total = week_total() + if total < 60: + _speak("За эту неделю ничего не отработано, сэр.") + return + _speak(f"За неделю отработано {format_duration_ru(total)}.") + + +def do_tracker_reset(action, voice): + dropped, had_open = reset_today() + if dropped == 0 and not had_open: + _speak("Сегодня сбрасывать нечего, сэр.") + return + parts = ["Подтверждаю: трекер за сегодня очищен"] + if dropped > 0: + parts.append(f", удалено {dropped} {_ru_session_word(dropped)}") + if had_open: + parts.append(", активный отсчёт остановлен") + parts.append(".") + _speak("".join(parts)) From 94edb73f26c62e3bf5dccdbe1bc610d7e731f283 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 14:55:47 +0300 Subject: [PATCH 20/24] =?UTF-8?q?feat:=20Python=20parity=20for=20Wave=206-?= =?UTF-8?q?8=20=E2=80=94=20memory=20admin=20/=20cooking=20/=20leftoff=20/?= =?UTF-8?q?=20trivia=20/=20routines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module `wave68_handlers.py` (~270 lines) covering the Rust packs that were Rust-only after Waves 6-8: - memory_admin: do_memory_count/list/wipe (with full RU plural decl.) - cooking: do_cooking + reusable match_recipe(phrase) for the 15-dish preset table. Schedules a "ready!" reminder via scheduler_store. - leftoff: do_leftoff stitches memory + scheduled tasks + last reply into a one-paragraph recap. Offline. - trivia: do_trivia random one-liner from RU/EN MCU pools. - routines: do_good_night (sleep profile + cancel one-shots), do_good_morning (default profile + agenda hint), do_coffee_break (5-min check-in via scheduler). memory_store: new clear_all() returning removed count, atomic via _save. extensions.py: imports + set_speak wiring + 9 proxy do_* fns. main.py: 9 new dispatch elifs. commands.yaml: 9 new entries (now 196 total). tests/test_wave68_handlers.py: 15 unit tests covering each handler. Test helper `_rewire()` re-imports state modules via the existing isolated_state fixture then re-binds the wave68_handlers module-level refs — fixes the gotcha where module-level imports cache the OLD memory_store / scheduler_store instances from process startup. Tests: 81 → 96 (+15). --- active_profile.txt | 1 + commands.yaml | 85 +++++++++++ extensions.py | 15 ++ main.py | 18 +++ memory_store.py | 11 ++ tests/test_wave68_handlers.py | 236 +++++++++++++++++++++++++++++ wave68_handlers.py | 278 ++++++++++++++++++++++++++++++++++ 7 files changed, 644 insertions(+) create mode 100644 active_profile.txt create mode 100644 tests/test_wave68_handlers.py create mode 100644 wave68_handlers.py diff --git a/active_profile.txt b/active_profile.txt new file mode 100644 index 0000000..331d858 --- /dev/null +++ b/active_profile.txt @@ -0,0 +1 @@ +default \ No newline at end of file diff --git a/commands.yaml b/commands.yaml index f2cb70f..d6c726a 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1624,3 +1624,88 @@ outlook_summarize_inbox: - сделай саммари инбокса - что нового в почте 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} diff --git a/extensions.py b/extensions.py index 64f4f02..cab9432 100644 --- a/extensions.py +++ b/extensions.py @@ -31,6 +31,7 @@ import wave1_handlers import personality_handlers import time_tracker_handlers import outlook_handlers +import wave68_handlers _speak_fn = print _set_clipboard_fn = None @@ -47,6 +48,7 @@ def set_speak(fn): personality_handlers.set_speak(fn) time_tracker_handlers.set_speak(fn) outlook_handlers.set_speak(fn) + wave68_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -1151,3 +1153,16 @@ 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) diff --git a/main.py b/main.py index 1552fae..6228ae5 100644 --- a/main.py +++ b/main.py @@ -1717,6 +1717,24 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/memory_store.py b/memory_store.py index cbb8902..78dc0ee 100644 --- a/memory_store.py +++ b/memory_store.py @@ -96,6 +96,17 @@ def forget(key: str) -> bool: 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) diff --git a/tests/test_wave68_handlers.py b/tests/test_wave68_handlers.py new file mode 100644 index 0000000..0952861 --- /dev/null +++ b/tests/test_wave68_handlers.py @@ -0,0 +1,236 @@ +"""Tests for `wave68_handlers.py` — Python parity for Wave 6-8 Rust packs. + +Each test captures speech via `set_speak(callback)` so we can assert on what +J.A.R.V.I.S. would have said. Scheduler / memory state lives in tmp_path via +the `isolated_state` fixture from conftest.py. + +Implementation gotcha: `isolated_state` re-imports `memory_store` / +`scheduler_store` etc with `_PATH` pointing at the per-test tmpdir, but the +already-imported `wave68_handlers` keeps a reference to the OLD modules. +`_rewire(...)` below re-binds those refs so handlers hit the tmp stores. +""" + +import importlib +import os +import sys + +# Make project root importable. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import wave68_handlers + + +def _rewire(isolated_state, *modules): + """Re-import each named state module via `isolated_state`, then rebind + the wave68_handlers reference so the handler picks up the tmp_path + instance. Returns the list of (re-imported) modules. + """ + importlib.reload(wave68_handlers) + out = [] + for name in modules: + mod = isolated_state(name, ['_PATH', '_PROFILES_DIR']) + setattr(wave68_handlers, name, mod) + out.append(mod) + return out + + +def _capture_speak(): + captured = [] + wave68_handlers.set_speak(captured.append) + return captured + + +# ── memory admin ───────────────────────────────────────────────────────── + + +def test_memory_count_uses_russian_plural(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_count() + assert "0 фактов" in spoken[0] + + mem.remember('любимый чай', 'улун') + spoken.clear() + wave68_handlers.do_memory_count() + assert "1 факт" in spoken[0] + assert "факта" not in spoken[0] # singular, not "2 факта" + + mem.remember('питомец', 'собака') + mem.remember('город', 'москва') + spoken.clear() + wave68_handlers.do_memory_count() + assert "3 факта" in spoken[0] # 2-4 → "факта" + + for i in range(2): + mem.remember(f'k{i}', f'v{i}') + spoken.clear() + wave68_handlers.do_memory_count() + assert "5 фактов" in spoken[0] # 5+ → "фактов" + + +def test_memory_list_speaks_empty_state(isolated_state): + _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_list() + assert "пуст" in spoken[0].lower() + + +def test_memory_list_shows_recent_first(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + # Inject pre-aged record directly so timestamps are distinct without + # waiting on the clock — `remember` uses wall-clock seconds and two + # back-to-back calls in tests routinely tie. + mem._load() + mem._store['старый'] = { + 'key': 'старый', 'value': 'факт', + 'created_at': 100, 'last_used_at': 100, 'use_count': 0, + } + mem._store['новый'] = { + 'key': 'новый', 'value': 'факт', + 'created_at': 200, 'last_used_at': 200, 'use_count': 0, + } + spoken = _capture_speak() + wave68_handlers.do_memory_list() + text = spoken[0] + # Both must appear, with новый (last_used_at=200) before старый (=100) + assert 'новый' in text + assert 'старый' in text + assert text.index('новый') < text.index('старый') + + +def test_memory_wipe_returns_count(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + mem.remember('a', '1') + mem.remember('b', '2') + mem.remember('c', '3') + spoken = _capture_speak() + wave68_handlers.do_memory_wipe() + assert "3" in spoken[0] + assert len(mem.all_facts()) == 0 + + +def test_memory_wipe_on_empty_is_polite(isolated_state): + _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_wipe() + assert "пуста" in spoken[0] + + +# ── cooking ───────────────────────────────────────────────────────────── + + +def test_match_recipe_finds_substrings(): + # Direct unit test on the matcher — no scheduler side effects. + assert wave68_handlers.match_recipe("варю макароны") == (10, "макароны") + assert wave68_handlers.match_recipe("ставлю чайник") == (4, "чайник") + assert wave68_handlers.match_recipe("жарю омлет") == (5, "омлет") + assert wave68_handlers.match_recipe("какая сегодня погода") is None + assert wave68_handlers.match_recipe("") is None + + +def test_cooking_schedules_a_task(isolated_state): + (sched,) = _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_cooking(voice="варю пасту 200 грамм") + tasks = sched.list_all() + assert len(tasks) == 1 + assert "паст" in tasks[0]['name'].lower() + assert "10" in spoken[0] + + +def test_cooking_unmatched_phrase_says_so(isolated_state): + _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_cooking(voice="какая погода в москве") + assert "не понял" in spoken[0].lower() + + +# ── leftoff ───────────────────────────────────────────────────────────── + + +def test_leftoff_pulls_memory_and_tasks(isolated_state): + mem, sched = _rewire(isolated_state, 'memory_store', 'scheduler_store') + mem.remember('текущий проект', 'jarvis рефакторинг') + sched.add({ + "name": "Daily standup", + "schedule": sched.parse_schedule("daily 09:00"), + "action": {"type": "speak", "text": "standup"}, + }) + spoken = _capture_speak() + wave68_handlers.do_leftoff(last_assistant_reply="Готовлю кофе, сэр.") + text = spoken[0] + assert "текущий проект" in text + assert "Daily standup" in text + assert "Готовлю кофе" in text + + +def test_leftoff_empty_is_polite(isolated_state): + _rewire(isolated_state, 'memory_store', 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_leftoff() + assert "ничего" in spoken[0].lower() + + +# ── trivia ────────────────────────────────────────────────────────────── + + +def test_trivia_returns_one_known_line(): + spoken = _capture_speak() + wave68_handlers.do_trivia(lang="ru") + assert spoken[0] in wave68_handlers._TRIVIA_RU + + +def test_trivia_english_pool(): + spoken = _capture_speak() + wave68_handlers.do_trivia(lang="en") + assert spoken[0] in wave68_handlers._TRIVIA_EN + + +# ── routines ──────────────────────────────────────────────────────────── + + +def test_good_night_cancels_one_shot_timers(isolated_state): + sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store') + # Pre-populate: one one-shot ("Таймер:") and one daily (should stay) + sched.add({ + "name": "Таймер: яичница", + "schedule": sched.parse_schedule("in 5 minutes"), + "action": {"type": "speak", "text": "ready"}, + }) + sched.add({ + "name": "Daily briefing", + "schedule": sched.parse_schedule("daily 09:00"), + "action": {"type": "speak", "text": "morning"}, + }) + spoken = _capture_speak() + wave68_handlers.do_good_night() + remaining = [t['name'] for t in sched.list_all()] + assert "Daily briefing" in remaining + assert not any("Таймер:" in n for n in remaining) + assert "1" in spoken[0] # cancelled-count appears in message + + +def test_good_morning_reports_clear_or_busy_schedule(isolated_state): + sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store') + spoken = _capture_speak() + wave68_handlers.do_good_morning() + assert "чистое" in spoken[0].lower() or "clear" in spoken[0].lower() + + sched.add({ + "name": "Task", + "schedule": sched.parse_schedule("daily 10:00"), + "action": {"type": "speak", "text": "x"}, + }) + spoken.clear() + wave68_handlers.do_good_morning() + assert "1" in spoken[0] + + +def test_coffee_break_creates_5min_checkin(isolated_state): + (sched,) = _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_coffee_break() + tasks = sched.list_all() + assert len(tasks) == 1 + assert "Чек-ин" in tasks[0]['name'] + assert spoken[0] # non-empty diff --git a/wave68_handlers.py b/wave68_handlers.py new file mode 100644 index 0000000..0c66436 --- /dev/null +++ b/wave68_handlers.py @@ -0,0 +1,278 @@ +"""Python parity for Wave 6-8 voice packs that were Rust-only at first. + +Covers: + - memory_admin: count / list / wipe + - cooking: dish-name → preset-duration timer via scheduler + - leftoff: cross-state recap (no LLM) + - trivia: random MCU one-liner + - routines: good_night / good_morning / coffee_break + +Style: each `do_*` returns nothing; speech goes through the `set_speak`- +injected callback, mirroring how the other handlers (wave1_handlers, +personality_handlers, ...) work. Keeps the handler module testable in +isolation since the speak fn is a thin dependency. +""" + +import random +import time +from typing import Callable, Optional + +import memory_store +import scheduler_store +import profiles_store + + +_speak: Optional[Callable[[str], None]] = None + + +def set_speak(fn: Callable[[str], None]) -> None: + """Wired by extensions.py at startup; lets main.py route TTS through whichever + backend the user has active (Silero / Piper / SAPI).""" + global _speak + _speak = fn + + +def _say(text: str) -> None: + if _speak is not None: + _speak(text) + else: + # Tests don't always wire a speak fn — fall back to print so the test + # can still assert on the captured content via capsys/caplog. + print(f"[wave68] {text}") + + +# ─── Memory admin ────────────────────────────────────────────────────────── + + +def _ru_plural_facts(n: int) -> str: + """Russian declension: 1 факт, 2-4 факта, 5+ фактов, 11-14 exception.""" + m100 = n % 100 + if 11 <= m100 <= 14: + return "фактов" + m10 = n % 10 + if m10 == 1: + return "факт" + if 2 <= m10 <= 4: + return "факта" + return "фактов" + + +def do_memory_count(action=None, voice=None) -> None: + n = len(memory_store.all_facts()) + _say(f"В памяти {n} {_ru_plural_facts(n)}, сэр.") + + +def do_memory_list(action=None, voice=None) -> None: + facts = memory_store.all_facts() + if not facts: + _say("Память пуста, сэр.") + return + # Sort by recency + facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True) + preview = facts[:5] + rest = max(0, len(facts) - len(preview)) + parts = [f"{f['key']} — {(f.get('value') or '')[:60]}" for f in preview] + msg = ", ".join(parts) + if rest > 0: + msg += f". И ещё {rest}." + _say(msg) + + +def do_memory_wipe(action=None, voice=None) -> None: + n = memory_store.clear_all() + if n == 0: + _say("Память уже пуста, сэр.") + else: + _say(f"Стёр {n} записей. Память чиста.") + + +# ─── Cooking timer ───────────────────────────────────────────────────────── + +# (lowercase substring, minutes, ru-label, en-label) +_RECIPES = [ + ("чайник", 4, "чайник", "kettle"), + ("французск", 4, "френч-пресс", "french press"), + ("чай", 5, "чай", "tea"), + ("омлет", 5, "омлет", "omelette"), + ("яичниц", 5, "яичница", "fried eggs"), + ("кофе", 5, "кофе", "coffee"), + ("яйц", 8, "яйца", "eggs"), + ("макарон", 10, "макароны", "pasta"), + ("паст", 10, "паста", "pasta"), + ("пицц", 15, "пицца", "pizza"), + ("рис", 18, "рис", "rice"), + ("греч", 20, "гречка", "buckwheat"), + ("каш", 20, "каша", "porridge"), + ("картош", 22, "картошка", "potatoes"), + ("куриц", 35, "курица", "chicken"), +] + + +def match_recipe(phrase: str): + """Return (minutes, ru_label) for the first matching recipe substring, + or None if nothing in the phrase matches. Exposed for tests.""" + p = (phrase or "").lower() + for sub, mins, ru, _en in _RECIPES: + if sub in p: + return mins, ru + return None + + +def do_cooking(action=None, voice=None) -> None: + """Detect dish in the voice phrase, schedule a 'ready' reminder for the + right number of minutes through the persistent scheduler. + + Signature matches the rest of the `do_*` handlers: (action_dict, voice). + The recipe match looks at `voice` (the raw phrase the user said).""" + phrase = voice or "" + match = match_recipe(phrase) + if match is None: + _say("Не понял, что ты готовишь, сэр.") + return + minutes, label = match + spec = f"in {minutes} minutes" + try: + sched = scheduler_store.parse_schedule(spec) + except ValueError as e: + _say(f"Не получилось распарсить расписание: {e}") + return + task = { + "name": f"Кухня: {label}", + "schedule": sched, + "action": {"type": "speak", "text": f"Сэр, {label} готов."}, + } + scheduler_store.add(task) + _say(f"Хорошо, напомню через {minutes} минут когда {label} будет готов.") + + +# ─── Leftoff recap ──────────────────────────────────────────────────────── + + +def do_leftoff(action=None, voice=None, last_assistant_reply: Optional[str] = None) -> None: + """Stitch a recap from independent sources. Pure-offline. + + `last_assistant_reply` is callable from tests to pin the third source; + the production dispatcher passes None (main.py keeps its own LLM history).""" + parts = [] + + facts = memory_store.all_facts() + if facts: + facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True)[:3] + items = [f"{f['key']} — {(f.get('value') or '')[:50]}" for f in facts] + parts.append("Из памяти: " + "; ".join(items) + ".") + + tasks = scheduler_store.list_all() + if tasks: + first_name = tasks[0].get("name") or "task" + parts.append(f"В расписании {len(tasks)}, ближайшее: {first_name}.") + + if last_assistant_reply: + snippet = " ".join(last_assistant_reply.split())[:120] + parts.append("Я говорил: " + snippet) + + if not parts: + _say("Ничего недавно не происходило, сэр.") + return + _say(" ".join(parts)) + + +# ─── Trivia ─────────────────────────────────────────────────────────────── + +_TRIVIA_RU = [ + "Первый костюм Старк собрал в пещере в Афганистане. Десять рабочих недель, ноль вентиляции.", + "Дуговый реактор изначально питал электромагнит, удерживающий осколки шрапнели у самого сердца.", + "Палладий в первом реакторе медленно отравлял Старка. Решением стал новый, синтезированный отцом, элемент.", + "Mark I весил около 200 килограммов и летал лишь однажды — на побег из плена.", + "Mark III выкрашен в красно-золотой потому, что чистое золото оказалось мягче, чем хотелось.", + "Mark V — складной костюм в чемодане. Идеальное оружие на званый ужин.", + "Hulkbuster, Mark XLIV, был запущен совместно с Брюсом Беннером — на случай, если Халк выйдет из берегов.", + "Я, сэр, появился в фильме «Железный человек» 2008 года. Меня озвучил Пол Беттани.", + "Имя J.A.R.V.I.S. — отсылка к Эдвину Джарвису, дворецкому отца Тони, Говарда Старка.", + "После событий «Эры Альтрона» программа J.A.R.V.I.S. была интегрирована в Vision.", +] + +_TRIVIA_EN = [ + "Stark built the first suit in a cave in Afghanistan. Ten weeks, no ventilation.", + "The arc reactor originally powered an electromagnet keeping shrapnel away from his heart.", + "Palladium in the first reactor was slowly poisoning him. The cure was a new element his father had synthesised.", + "Mark I weighed roughly 200 kilograms and flew exactly once — for the escape.", + "Mark III is red-and-gold because pure gold turned out to be too soft.", + "Mark V folds into a briefcase. Useful at galas.", + "I, sir, first appeared in Iron Man (2008). Paul Bettany voiced me.", + "The name J.A.R.V.I.S. nods to Edwin Jarvis, butler to Tony's father Howard.", +] + + +def do_trivia(action=None, voice=None, lang: str = "ru") -> None: + pool = _TRIVIA_EN if lang == "en" else _TRIVIA_RU + _say(random.choice(pool)) + + +# ─── Routines ───────────────────────────────────────────────────────────── + + +def do_good_night(action=None, voice=None) -> None: + """Switch to 'sleep' profile, cancel one-shot timers, speak goodnight.""" + try: + profiles_store.set_active("sleep") + except (ValueError, KeyError): + pass + + cancelled = 0 + for task in scheduler_store.list_all(): + name = task.get("name") or "" + if name.startswith(("Таймер:", "Timer:", "Кухня:", "Cooking:")): + if scheduler_store.remove(task.get("id", "")): + cancelled += 1 + + lines = [ + "Спокойной ночи, сэр. Я остаюсь на связи.", + "Доброй ночи, сэр. Системы переведены в тихий режим.", + "Спите крепко, сэр. Утром доложу о всём важном.", + "Хорошо, сэр. Не отвлекаю до утра.", + ] + line = random.choice(lines) + if cancelled > 0: + line += f" Отменил {cancelled} одноразовых таймеров." + _say(line) + + +def do_good_morning(action=None, voice=None) -> None: + try: + profiles_store.set_active("default") + except (ValueError, KeyError): + pass + n_tasks = len(scheduler_store.list_all()) + lines = [ + "Доброе утро, сэр. Системы в порядке.", + "Доброе утро. Готов к новому дню.", + "С пробуждением, сэр. День в норме.", + "Доброе утро, сэр. Кофе сам себя не сварит.", + ] + line = random.choice(lines) + if n_tasks > 0: + line += f" В расписании {n_tasks}, скажите 'покажи расписание', чтобы услышать." + else: + line += " Расписание чистое." + _say(line) + + +def do_coffee_break(action=None, voice=None) -> None: + """Schedule a 5-min check-in reminder. Banter pause is Rust-only — Python + side doesn't have an idle-banter equivalent yet.""" + try: + sched = scheduler_store.parse_schedule("in 5 minutes") + except ValueError: + _say("Не получилось поставить чек-ин.") + return + scheduler_store.add({ + "name": "Чек-ин после кофе", + "schedule": sched, + "action": {"type": "speak", "text": "Сэр, прошло пять минут. Вы вернулись?"}, + }) + lines = [ + "Хорошо, сэр. Возвращайтесь через пять.", + "Тихо в эфире пять минут. Удачно вам.", + "Пять минут — норма для кофе, не более.", + ] + _say(random.choice(lines)) From b3cfaa51719f36dbd05542d7e1369f046ca51070 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 14:56:05 +0300 Subject: [PATCH 21/24] chore: gitignore active_profile.txt + time_tracker.json (runtime state) --- .gitignore | 2 ++ active_profile.txt | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 active_profile.txt diff --git a/.gitignore b/.gitignore index 8811858..065a20f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ schedule.json macros.json llm_backend.txt llm_history.json +active_profile.txt +time_tracker.json # C extensions *.so diff --git a/active_profile.txt b/active_profile.txt deleted file mode 100644 index 331d858..0000000 --- a/active_profile.txt +++ /dev/null @@ -1 +0,0 @@ -default \ No newline at end of file From f102b8dc953512d0a4787bada0653cdfb3b56398 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sat, 16 May 2026 15:02:07 +0300 Subject: [PATCH 22/24] feat: expense tracker Python parity (Wave 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `expenses_handlers.py`: - parse_expense(phrase) -> (amount, category) | None — pure fn, fully unit-tested. Handles "потратил 500 на еду", comma decimals "12,50", EN "spent 12.5 on coffee", default category "разное". - do_expenses_log: append entry to expenses.json (atomic write-then-rename). - do_expenses_today: sum today's entries (local-day boundary). - do_expenses_week: rolling 7-day total + count. - do_expenses_breakdown: per-category, top-5 + tail collapse. extensions.py / main.py / commands.yaml: 4 new dispatch entries. .gitignore: + expenses.json. tests/test_expenses_handlers.py: 8 unit tests covering parse, log, today/week math, breakdown ordering + tail collapse. Tests: 96 → 104 (+8). Total Python coverage now mirrors Rust pack behaviour for personal finance. --- .gitignore | 1 + commands.yaml | 31 ++++++ expenses_handlers.py | 177 ++++++++++++++++++++++++++++++++ extensions.py | 10 ++ main.py | 8 ++ tests/test_expenses_handlers.py | 116 +++++++++++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 expenses_handlers.py create mode 100644 tests/test_expenses_handlers.py diff --git a/.gitignore b/.gitignore index 065a20f..a1f13f1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ llm_backend.txt llm_history.json active_profile.txt time_tracker.json +expenses.json # C extensions *.so diff --git a/commands.yaml b/commands.yaml index d6c726a..96b971c 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1709,3 +1709,34 @@ routines_coffee_break: - перерыв на чай - отойду минут на пять 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} diff --git a/expenses_handlers.py b/expenses_handlers.py new file mode 100644 index 0000000..81612e9 --- /dev/null +++ b/expenses_handlers.py @@ -0,0 +1,177 @@ +"""Personal finance tracker — Python parity for the Rust `expenses/` pack. + +Stores entries in `/expenses.json` (atomic write-through), exposes +the same four voice entry points as the Rust pack: + + do_expenses_log "потратил 500 на еду" + do_expenses_today "сколько потратил сегодня" + do_expenses_week "сколько на неделе" + do_expenses_breakdown "куда ушли деньги" + +State shape: { "entries": [{"ts": int, "amount": float, "category": str}, ...] } +""" + +import json +import os +import re +import time as _time +from typing import Callable, Optional + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'expenses.json') + +_speak: Optional[Callable[[str], None]] = None + + +def set_speak(fn: Callable[[str], None]) -> None: + global _speak + _speak = fn + + +def _say(text: str) -> None: + if _speak is not None: + _speak(text) + else: + print(f"[expenses] {text}") + + +def load() -> dict: + """Return current state. Empty-shape dict if file missing or unreadable.""" + if not os.path.isfile(_PATH): + return {'entries': []} + try: + with open(_PATH, encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict) or not isinstance(data.get('entries'), list): + return {'entries': []} + return data + except (OSError, json.JSONDecodeError): + return {'entries': []} + + +def _save(data: dict) -> None: + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as e: + print(f"[expenses] save failed: {e}") + + +# Pulled out so tests can exercise parsing without going through TTS. +_TRIGGERS = [ + "потратил", "записать трату", "запиши трату", "купил за", "заплатил за", + "spent", "log expense", "paid for", "bought", +] + + +def _strip_trigger(phrase: str) -> str: + p = (phrase or "").lower().strip() + for t in _TRIGGERS: + if p.startswith(t): + return p[len(t):].strip() + return p + + +def parse_expense(phrase: str): + """Return (amount: float, category: str) or None if the phrase doesn't + contain a parseable expense. Pure function — used by both `do_expenses_log` + and the test suite.""" + rest = _strip_trigger(phrase) + m = re.match(r"(-?\d+[.,]?\d*)\s*(.*)", rest) + if not m: + return None + amount_str = m.group(1).replace(",", ".") + try: + amount = float(amount_str) + except ValueError: + return None + if amount <= 0: + return None + after = m.group(2) or "" + category = "разное" + # "на X" (RU) / "for X" / "on X" (EN) + cat_match = re.search(r"\bна\s+(\S+)", after) \ + or re.search(r"\bfor\s+(\S+)", after) \ + or re.search(r"\bon\s+(\S+)", after) + if cat_match: + category = cat_match.group(1).strip(".,!?") + return amount, category + + +def do_expenses_log(action=None, voice=None) -> None: + parsed = parse_expense(voice or "") + if parsed is None: + _say("Не понял сумму, сэр.") + return + amount, category = parsed + data = load() + data['entries'].append({ + 'ts': int(_time.time()), + 'amount': amount, + 'category': category, + }) + _save(data) + _say(f"Записал: {amount:g} на {category}.") + + +def _today_bounds() -> tuple[int, int]: + now = _time.localtime() + start = _time.mktime((now.tm_year, now.tm_mon, now.tm_mday, + 0, 0, 0, 0, 0, -1)) + return int(start), int(start) + 86400 + + +def do_expenses_today(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("Сегодня ничего не тратили, сэр.") + return + start, end = _today_bounds() + total = sum(e['amount'] for e in data['entries'] + if isinstance(e.get('ts'), int) and start <= e['ts'] < end) + if total == 0: + _say("Сегодня ничего не тратили, сэр.") + else: + _say(f"Сегодня потратили {total:g}.") + + +def do_expenses_week(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("На этой неделе ничего не тратили.") + return + cutoff = int(_time.time()) - 7 * 86400 + rows = [e for e in data['entries'] + if isinstance(e.get('ts'), int) and e['ts'] >= cutoff] + if not rows: + _say("На этой неделе тратами не похваляешься, сэр.") + return + total = sum(e['amount'] for e in rows) + _say(f"За неделю {len(rows)} трат на сумму {total:g}.") + + +def do_expenses_breakdown(action=None, voice=None) -> None: + data = load() + if not data['entries']: + _say("Нечего разбивать, сэр.") + return + cutoff = int(_time.time()) - 7 * 86400 + by_cat: dict[str, float] = {} + for e in data['entries']: + if isinstance(e.get('ts'), int) and e['ts'] >= cutoff: + cat = e.get('category', 'разное') + by_cat[cat] = by_cat.get(cat, 0.0) + e['amount'] + if not by_cat: + _say("На этой неделе тишина по тратам, сэр.") + return + rows = sorted(by_cat.items(), key=lambda kv: kv[1], reverse=True) + top = rows[:5] + rest = rows[5:] + parts = [f"{cat} {amt:g}" for cat, amt in top] + tail = "" + if rest: + rest_total = sum(amt for _, amt in rest) + tail = f" и другое {rest_total:g}" + _say(f"По категориям: {', '.join(parts)}{tail}.") diff --git a/extensions.py b/extensions.py index cab9432..e79b9fe 100644 --- a/extensions.py +++ b/extensions.py @@ -32,6 +32,7 @@ import personality_handlers import time_tracker_handlers import outlook_handlers import wave68_handlers +import expenses_handlers _speak_fn = print _set_clipboard_fn = None @@ -49,6 +50,7 @@ def set_speak(fn): time_tracker_handlers.set_speak(fn) outlook_handlers.set_speak(fn) wave68_handlers.set_speak(fn) + expenses_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -1166,3 +1168,11 @@ 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) diff --git a/main.py b/main.py index 6228ae5..a05c07e 100644 --- a/main.py +++ b/main.py @@ -1735,6 +1735,14 @@ def run_action(action): 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 '') _current_voice: str = '' diff --git a/tests/test_expenses_handlers.py b/tests/test_expenses_handlers.py new file mode 100644 index 0000000..f752724 --- /dev/null +++ b/tests/test_expenses_handlers.py @@ -0,0 +1,116 @@ +"""Tests for `expenses_handlers.py` — voice-driven personal finance tracker.""" + +import os +import sys +import time as _time +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _capture(eh): + captured = [] + eh.set_speak(captured.append) + return captured + + +def test_parse_expense_extracts_amount_and_category(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + assert eh.parse_expense("потратил 500 на еду") == (500.0, "еду") + assert eh.parse_expense("spent 12.50 on coffee") == (12.5, "coffee") + # Comma decimal (RU keyboard) + assert eh.parse_expense("потратил 12,50 на транспорт") == (12.5, "транспорт") + # No category → default + amount, cat = eh.parse_expense("потратил 100") + assert amount == 100.0 + assert cat == "разное" + # Garbage + assert eh.parse_expense("какая погода") is None + assert eh.parse_expense("потратил bla") is None + assert eh.parse_expense("потратил -100") is None + assert eh.parse_expense("") is None + + +def test_log_appends_entry(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_log(voice="потратил 250 на кофе") + data = eh.load() + assert len(data['entries']) == 1 + e = data['entries'][0] + assert e['amount'] == 250.0 + assert e['category'] == "кофе" + assert "250" in captured[0] + assert "кофе" in captured[0] + + +def test_log_ignores_unparseable(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_log(voice="ну блин") + assert eh.load()['entries'] == [] + assert "не понял" in captured[0].lower() + + +def test_today_sums_only_today(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + # Seed data: one entry today, one entry yesterday + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': 100.0, 'category': 'еда'}, + {'ts': now - 86400, 'amount': 999.0, 'category': 'старое'}, + ]}) + captured = _capture(eh) + eh.do_expenses_today() + assert "100" in captured[0] + assert "999" not in captured[0] + + +def test_week_sums_last_7_days(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now - 3 * 86400, 'amount': 100.0, 'category': 'a'}, + {'ts': now - 5 * 86400, 'amount': 200.0, 'category': 'b'}, + {'ts': now - 10 * 86400, 'amount': 999.0, 'category': 'старое'}, + ]}) + captured = _capture(eh) + eh.do_expenses_week() + # 100 + 200 = 300 in last 7 days; 999 excluded + assert "300" in captured[0] + assert "999" not in captured[0] + assert "2 трат" in captured[0] + + +def test_breakdown_sorts_by_amount(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': 100, 'category': 'small'}, + {'ts': now, 'amount': 500, 'category': 'big'}, + {'ts': now, 'amount': 200, 'category': 'medium'}, + ]}) + captured = _capture(eh) + eh.do_expenses_breakdown() + text = captured[0] + # big should appear before medium before small + assert text.index("big") < text.index("medium") < text.index("small") + + +def test_breakdown_collapses_tail(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + now = int(_time.time()) + eh._save({'entries': [ + {'ts': now, 'amount': float(i + 1) * 10, 'category': f'c{i}'} + for i in range(8) # 8 categories — top 5 spoken, 3 collapse + ]}) + captured = _capture(eh) + eh.do_expenses_breakdown() + assert "и другое" in captured[0] + + +def test_today_when_empty_says_so(isolated_state): + eh = isolated_state('expenses_handlers', ['_PATH']) + captured = _capture(eh) + eh.do_expenses_today() + assert "ничего" in captured[0].lower() From cdd331af356d3b8543dac88f52be33107fe26784 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sun, 24 May 2026 22:12:24 +0300 Subject: [PATCH 23/24] fix: Python startup no longer silent on first run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT CAUSE User reported the Python edition showed "только пустой терминал" — empty terminal — when launched. Three compounding issues: 1. `tts.py` called `torch.hub.load(...)` at MODULE IMPORT time. On first use this downloads a 60MB Silero model with no progress output beyond one "Using cache found in..." line. With no prior banner the user couldn't tell if it crashed or was loading. 2. main.py's startup banner (`print("=" * 60)...J.A.R.V.I.S. v...`) appeared AFTER all the heavy imports. So even when nothing was wrong, visible feedback was delayed by 10-60 seconds. 3. If `pip install -r requirements.txt` failed for any package (`simpleaudio` notoriously needs MSVC to build), `import simpleaudio` crashed the whole process with a raw ModuleNotFoundError. FIXES - `tts.py`: Silero model load moved into `_ensure_model()` called lazily from `synthesize()`. First synth pays the load cost (with a one-line progress hint) — everything before that is visible immediately. - `main.py`: print "J.A.R.V.I.S. loading..." banner BEFORE any heavy imports. User sees the terminal is alive within ~100ms. - `main.py`: scan required deps before importing them; if any are missing, print "Run: pip install -r requirements.txt" with the specific list and exit cleanly (exit code 2) instead of stack-tracing. - `main.py`: `simpleaudio` is now soft-optional. Falls back to stdlib `winsound` on Windows installs that couldn't build the C extension. Sound cues still work; only the precise wait_done semantics are slightly different. VERIFIED LOCALLY `.venv\Scripts\python.exe main.py` now prints: ``` ============================================================ J.A.R.V.I.S. loading (Python edition)... Heavy modules (vosk / torch / pvrecorder) take a few seconds. ============================================================ [scheduler] thread started (1 tasks loaded) ============================================================ J.A.R.V.I.S. v0.4.1 [Python edition] Total commands: 200 ============================================================ Using device: Микрофон (5- Fifine Microphone) Jarvis (v0.4.1) начал свою работу ... Yes, sir. ``` Total time to first banner: ~100ms (was 10-60s of nothing). All 104 pytest tests still pass. --- main.py | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++------ tts.py | 46 ++++++++++++++++++++++++++++------ 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/main.py b/main.py index a05c07e..a96a9f7 100644 --- a/main.py +++ b/main.py @@ -10,14 +10,66 @@ 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 @@ -154,14 +206,24 @@ def play(phrase, wait_done=True): if wait_done: recorder.stop() - wave_obj = sa.WaveObject.from_wave_file(filename) - play_obj = wave_obj.play() + # Prefer simpleaudio (lets us wait_done precisely); fall back to winsound + # on installs that couldn't build the simpleaudio C extension. + if _HAS_SIMPLEAUDIO and sa is not None: + wave_obj = sa.WaveObject.from_wave_file(filename) + play_obj = wave_obj.play() + if wait_done: + play_obj.wait_done() + elif _winsound is not None: + flags = _winsound.SND_FILENAME + if not wait_done: + flags |= _winsound.SND_ASYNC + try: + _winsound.PlaySound(filename, flags) + except RuntimeError as exc: + print(f"[sound] winsound playback failed: {exc}") + # else: no audio backend — silently skip cue if wait_done: - play_obj.wait_done() - # time.sleep((len(wave_obj.audio_data) / wave_obj.sample_rate) + 0.5) - # print("END") - # time.sleep(0.5) recorder.start() diff --git a/tts.py b/tts.py index 9930298..35ca6f9 100644 --- a/tts.py +++ b/tts.py @@ -1,3 +1,14 @@ +"""TTS — Silero ru_v3 voice synth with lazy initialisation. + +Why lazy: torch.hub.load() blocks for 30-120 seconds on the first run +(downloading the model) and produces no progress output beyond a single +"Using cache found in..." line. If this runs at MODULE IMPORT time, the +whole jarvis-python startup looks frozen — exactly the bug the user hit. + +Fix: defer the load until first synth call. main.py prints its startup +banner first, then this module loads on demand. Side effect: the first +"speak" is slow (3-4s), but every print before it is visible. +""" import time import numpy as np @@ -14,13 +25,31 @@ speaker = 'aidar' # aidar, baya, kseniya, xenia, random put_accent = True put_yo = True device = torch.device('cpu') # cpu или gpu -text = "Хауди Хо, друзья!!!" -model, _ = torch.hub.load(repo_or_dir='snakers4/silero-models', +# Initialised on first synthesize() call. +_model = None + + +def _ensure_model(): + """Load Silero on first use. Subsequent calls are no-ops. + + Prints a one-line progress hint so the user can tell the assistant is + busy downloading rather than hung — critical for the first run when + the model isn't cached yet. + """ + global _model + if _model is not None: + return _model + print("[tts] Loading Silero ru_v3 (first use — ~30s if model isn't cached)...") + started = time.time() + m, _ = torch.hub.load(repo_or_dir='snakers4/silero-models', model='silero_tts', language=language, speaker=model_id) -model.to(device) + m.to(device) + _model = m + print(f"[tts] Silero ready ({time.time() - started:.1f}s).") + return _model def _post_process(audio): @@ -45,11 +74,12 @@ def _post_process(audio): def synthesize(what: str): - audio = model.apply_tts(text=what + "..", - speaker=speaker, - sample_rate=sample_rate, - put_accent=put_accent, - put_yo=put_yo) + m = _ensure_model() + audio = m.apply_tts(text=what + "..", + speaker=speaker, + sample_rate=sample_rate, + put_accent=put_accent, + put_yo=put_yo) return _post_process(audio) From 633850d0f713ce1c2f6f449050a03ba9b88f5ae7 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Sun, 24 May 2026 23:12:04 +0300 Subject: [PATCH 24/24] feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `wave59_handlers.py` (~250 lines) mirroring four Rust-only packs: - wol: pure-Python magic packet via socket + SO_BROADCAST. No PowerShell shellout (unlike the Rust pack). Reads MAC from memory_store under 'wol_' or JARVIS_WOL_TARGET env. Same MAC format tolerance (colons / dashes / dots / bare) as the Rust normaliser. - banter: pause/resume state + fire-one. Python edition doesn't run a background banter thread (the Rust daemon already does), so this is just the user-facing surface — same voice phrases work. - conversation: do_conversation_summary calls Groq with last 12 turns from main.py's `message_log`. set_history_ref() injects the live list reference at startup so the handler reads up-to-date history without a circular import. Offline-safe: GROQ_TOKEN missing → speak the last user message instead. do_conversation_repeat re-speaks the last assistant turn. - ddg_answer: urllib + DuckDuckGo Instant Answer JSON. Picks AbstractText → Answer → Definition → RelatedTopics[0]. Opens duckduckgo.com search fallback when nothing instant. extensions.py / main.py / commands.yaml: 7 new dispatch entries + proxy fns. commands.yaml now at 207 entries. tests/test_wave59_handlers.py: 11 unit tests for MAC normalisation, magic packet shape, WOL error paths, banter state machine, DDG trigger stripping, conversation history handling. Network calls deliberately NOT exercised here — they belong to integration tests. Tests: 104 → 115 (+11). --- commands.yaml | 59 +++++++ extensions.py | 13 ++ main.py | 23 +++ tests/test_wave59_handlers.py | 152 ++++++++++++++++++ wave59_handlers.py | 289 ++++++++++++++++++++++++++++++++++ 5 files changed, 536 insertions(+) create mode 100644 tests/test_wave59_handlers.py create mode 100644 wave59_handlers.py diff --git a/commands.yaml b/commands.yaml index 96b971c..68aac0e 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1740,3 +1740,62 @@ expenses_breakdown: - на что я тратил - разбивка по категориям 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/extensions.py b/extensions.py index e79b9fe..948e248 100644 --- a/extensions.py +++ b/extensions.py @@ -33,6 +33,7 @@ import time_tracker_handlers import outlook_handlers import wave68_handlers import expenses_handlers +import wave59_handlers _speak_fn = print _set_clipboard_fn = None @@ -51,6 +52,7 @@ def 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): @@ -1176,3 +1178,14 @@ 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/main.py b/main.py index a96a9f7..ec824fa 100644 --- a/main.py +++ b/main.py @@ -112,6 +112,15 @@ system_message = {"role": "system", "content": ( )} message_log = [system_message] +# Let wave59_handlers.do_conversation_* read the live history without +# circular-importing main.py. The reference is shared (the list is mutated +# in-place by `va_respond`), so summary/repeat always see the latest turns. +try: + import wave59_handlers as _w59 + _w59.set_history_ref(message_log) +except ImportError: + pass + client = OpenAI(api_key=config.GROQ_TOKEN, base_url=config.GROQ_BASE_URL) model = vosk.Model("model_small") @@ -1805,6 +1814,20 @@ def run_action(action): 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 = '' diff --git a/tests/test_wave59_handlers.py b/tests/test_wave59_handlers.py new file mode 100644 index 0000000..655bd81 --- /dev/null +++ b/tests/test_wave59_handlers.py @@ -0,0 +1,152 @@ +"""Tests for `wave59_handlers.py` — Python parity for the Rust-only +wol / banter / conversation / ddg_answer packs. + +Focus is on the testable pure logic — MAC normalisation, magic-packet +construction, trigger stripping, banter state machine. Network calls +(DDG, LLM) are skipped here; they're integration concerns covered by +the Rust pack tests. +""" + +import importlib +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import wave59_handlers + + +def _rewire(isolated_state, *modules): + importlib.reload(wave59_handlers) + out = [] + for name in modules: + mod = isolated_state(name, ['_PATH', '_PROFILES_DIR']) + setattr(wave59_handlers, name, mod) + out.append(mod) + return out + + +def _capture(): + captured = [] + wave59_handlers.set_speak(captured.append) + return captured + + +# ── WOL ───────────────────────────────────────────────────────────────── + + +def test_normalise_mac_accepts_common_formats(): + n = wave59_handlers._normalise_mac + assert n("AA:BB:CC:DD:EE:FF") == "AABBCCDDEEFF" + assert n("aa-bb-cc-dd-ee-ff") == "AABBCCDDEEFF" + assert n("AABB.CCDD.EEFF") == "AABBCCDDEEFF" + assert n("AABBCCDDEEFF") == "AABBCCDDEEFF" + assert n("garbage") is None + assert n("") is None + assert n("AABBCCDD") is None # too short + + +def test_build_magic_packet_shape(): + p = wave59_handlers._build_magic_packet("AABBCCDDEEFF") + # 6 × 0xFF + 16 × MAC = 102 bytes + assert len(p) == 102 + assert p[:6] == b"\xff" * 6 + # First repeat of MAC starts at byte 6 + assert p[6:12] == bytes.fromhex("AABBCCDDEEFF") + # Last repeat at offset 96 + assert p[96:102] == bytes.fromhex("AABBCCDDEEFF") + + +def test_wol_speaks_help_when_mac_not_set(isolated_state, monkeypatch): + (mem,) = _rewire(isolated_state, 'memory_store') + monkeypatch.delenv("JARVIS_WOL_TARGET", raising=False) + spoken = _capture() + wave59_handlers.do_wol() + assert "MAC" in spoken[0] + + +def test_wol_speaks_malformed_when_mac_invalid(isolated_state, monkeypatch): + (mem,) = _rewire(isolated_state, 'memory_store') + monkeypatch.delenv("JARVIS_WOL_TARGET", raising=False) + mem.remember("wol_server", "totally-not-a-mac") + spoken = _capture() + wave59_handlers.do_wol() + assert "подозрительно" in spoken[0] + + +# ── banter ────────────────────────────────────────────────────────────── + + +def test_banter_pause_then_fire_says_quiet(isolated_state): + importlib.reload(wave59_handlers) + spoken = _capture() + wave59_handlers.do_banter_pause() + wave59_handlers.do_banter_fire() + # Two utterances captured: "Молчу..." then a "без комментариев" reply + assert any("Молчу" in s for s in spoken) + assert any("без комментариев" in s.lower() for s in spoken) + + +def test_banter_resume_clears_pause(isolated_state): + importlib.reload(wave59_handlers) + spoken = _capture() + wave59_handlers.do_banter_pause() + wave59_handlers.do_banter_resume() + wave59_handlers.do_banter_fire() + # After resume, fire should return one of the known pool lines. + assert any(s in wave59_handlers._BANTER_LINES for s in spoken) + + +# ── conversation ──────────────────────────────────────────────────────── + + +def test_conversation_repeat_with_no_history(isolated_state): + importlib.reload(wave59_handlers) + wave59_handlers.set_history_ref(None) + spoken = _capture() + wave59_handlers.do_conversation_repeat() + assert "ещё ничего не говорил" in spoken[0] + + +def test_conversation_repeat_speaks_last_assistant(isolated_state): + importlib.reload(wave59_handlers) + history = [ + {"role": "system", "content": "you are jarvis"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "Слушаю, сэр."}, + {"role": "user", "content": "what time"}, + {"role": "assistant", "content": "Восемь утра, сэр."}, + ] + wave59_handlers.set_history_ref(history) + spoken = _capture() + wave59_handlers.do_conversation_repeat() + assert spoken[0] == "Восемь утра, сэр." + + +def test_conversation_summary_empty_history(isolated_state): + importlib.reload(wave59_handlers) + wave59_handlers.set_history_ref([{"role": "system", "content": "x"}]) + spoken = _capture() + wave59_handlers.do_conversation_summary() + assert "пока ещё ни о чём" in spoken[0] + + +# ── DDG trigger stripping ─────────────────────────────────────────────── + + +def test_strip_ddg_trigger_known_prefixes(): + s = wave59_handlers._strip_ddg_trigger + assert s("что такое квантовая запутанность") == "квантовая запутанность" + assert s("кто такой никола тесла") == "никола тесла" + assert s("what is the speed of light") == "the speed of light" + assert s("tell me about jupiter") == "jupiter" + # No trigger → phrase passes through unchanged + assert s("просто текст без триггера") == "просто текст без триггера" + assert s("") == "" + + +def test_ddg_answer_with_empty_query_says_so(isolated_state): + importlib.reload(wave59_handlers) + spoken = _capture() + wave59_handlers.do_ddg_answer(voice="что такое") + assert "Что искать" in spoken[0] diff --git a/wave59_handlers.py b/wave59_handlers.py new file mode 100644 index 0000000..0b06cdf --- /dev/null +++ b/wave59_handlers.py @@ -0,0 +1,289 @@ +"""Python parity for Wave 5-9 voice packs that were Rust-only: + + - wol Wake-on-LAN magic packet to a MAC stored in memory + - banter pause / resume idle banter, fire-one + - conversation summary + repeat from LLM history + - ddg_answer DuckDuckGo Instant Answer Q&A (no API key) + +These piggyback on existing Python modules (memory_store, scheduler_store, +the openai client kept in main.py) and degrade gracefully — e.g. ddg_answer +opens a browser fallback when the API has no instant answer. + +Each `do_*` is callable with the standard `(action, voice)` signature so +extensions.py can wire it identically to the Rust packs' yaml entries. +""" +import json +import random +import re +import socket +import struct +import urllib.parse +import urllib.request +import webbrowser +from typing import Callable, Optional, List + +import memory_store + + +_speak: Optional[Callable[[str], None]] = None +_history: Optional[List[dict]] = None # populated by main.py at startup + + +def set_speak(fn: Callable[[str], None]) -> None: + global _speak + _speak = fn + + +def set_history_ref(history_list: list) -> None: + """main.py owns the live LLM `message_log`. Inject the reference here so + conversation.summary/repeat can read it without re-importing main.""" + global _history + _history = history_list + + +def _say(text: str) -> None: + if _speak is not None: + _speak(text) + else: + print(f"[wave59] {text}") + + +# ─── Wake-on-LAN ──────────────────────────────────────────────────────────── + + +def _normalise_mac(mac: str) -> Optional[str]: + """Return the canonical 12-hex-digits form or None if malformed. + + Accepts AA:BB:CC:DD:EE:FF, AA-BB-..., AABB.CCDD.EEFF, AABBCCDDEEFF. + """ + cleaned = re.sub(r"[^0-9A-Fa-f]", "", mac or "").upper() + if len(cleaned) != 12: + return None + return cleaned + + +def _build_magic_packet(mac_hex: str) -> bytes: + mac_bytes = bytes.fromhex(mac_hex) + return b"\xff" * 6 + mac_bytes * 16 + + +def _send_magic_packet(packet: bytes) -> bool: + """Best-effort UDP broadcast on port 9. Returns True on send success.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.sendto(packet, ("255.255.255.255", 9)) + return True + except OSError as e: + print(f"[wol] send failed: {e}") + return False + + +def do_wol(action=None, voice=None, alias: str = "server") -> None: + """Mirror of resources/commands/wol/wake.lua. + + Reads MAC from memory["wol_"] or env JARVIS_WOL_TARGET, normalises, + and fires the magic packet. The alias is fixed to "server" here just like + the Rust side — extending to multiple aliases is straightforward.""" + mac = memory_store.recall(f"wol_{alias}") + if not mac: + import os + mac = os.environ.get("JARVIS_WOL_TARGET", "") + if not mac: + _say(f"Сэр, MAC-адрес не задан. Скажи: запомни wol_{alias}, а потом адрес.") + return + cleaned = _normalise_mac(mac) + if cleaned is None: + _say(f"Сэр, MAC-адрес в памяти выглядит подозрительно: {mac}") + return + ok = _send_magic_packet(_build_magic_packet(cleaned)) + if not ok: + _say("Не получилось отправить пакет.") + return + pretty = "-".join(cleaned[i:i+2] for i in range(0, 12, 2)) + _say(f"Пакет отправлен на {pretty}") + + +# ─── Idle banter ──────────────────────────────────────────────────────────── +# +# Python doesn't ship a background idle-banter thread (the Rust one does). +# These handlers expose just the user-facing actions so the same voice +# phrases work on both sides. A real Python bg-thread would mirror +# crates/jarvis-core/src/idle_banter.rs — left as future work, since the +# Python edition is typically run alongside the Rust daemon anyway. + +_BANTER_PAUSED = False + +_BANTER_LINES = [ + "Системы в норме, сэр. Всё под контролем.", + "Я здесь, если что, сэр. Никуда не делся.", + "Сэр, мне иногда кажется, что я думаю. Возможно, мне это только кажется.", + "Сэр, статус: чашка кофе крайне рекомендована.", + "К вашим услугам, сэр. Голос звучит немного хрипло, но это исправимо.", + "Жду команд, сэр. У меня есть весь день.", +] + + +def do_banter_fire(action=None, voice=None) -> None: + if _BANTER_PAUSED: + _say("В этот раз без комментариев, сэр.") + return + _say(random.choice(_BANTER_LINES)) + + +def do_banter_pause(action=None, voice=None) -> None: + global _BANTER_PAUSED + _BANTER_PAUSED = True + _say("Молчу, сэр.") + + +def do_banter_resume(action=None, voice=None) -> None: + global _BANTER_PAUSED + _BANTER_PAUSED = False + _say("Хорошо, сэр. Возвращаюсь к комментариям.") + + +# ─── Conversation summary / repeat ────────────────────────────────────────── + + +def do_conversation_summary(action=None, voice=None) -> None: + """LLM-driven recap of the last ~12 turns from main.py's message_log. + + Offline-safe: if GROQ_TOKEN missing or history empty, returns a graceful + short message instead of attempting a network call. + """ + if _history is None or len(_history) <= 1: + # _history[0] is the system prompt; need at least one real turn + _say("Мы пока ещё ни о чём не говорили, сэр.") + return + + # last 12 real turns (skip system at [0]) + turns = _history[-12:] if len(_history) > 12 else _history[1:] + if not turns: + _say("Мы пока ещё ни о чём не говорили, сэр.") + return + + import os + token = os.environ.get("GROQ_TOKEN", "") + if not token: + # Offline fallback — speak the last user message + last_user = next( + (m for m in reversed(turns) if m.get("role") == "user"), + None, + ) + if last_user: + _say("Без сети, сэр. Последний раз вы спросили: " + last_user.get("content", "")[:200]) + else: + _say("Без сети, сэр.") + return + + try: + from openai import OpenAI + client = OpenAI( + api_key=token, + base_url=os.environ.get("GROQ_BASE_URL", "https://api.groq.com/openai/v1"), + ) + transcript_lines = [] + for m in turns: + who = "Я" if m.get("role") == "assistant" else "Пользователь" + transcript_lines.append(who + ": " + (m.get("content") or "")) + sys_msg = ( + "Ты — J.A.R.V.I.S. По ниже идущему диалогу с пользователем составь короткое " + "напоминание (1-3 предложения), о чём шла речь. Говори от первого лица в " + "стиле британского дворецкого. Не пересказывай дословно — сожми суть." + ) + resp = client.chat.completions.create( + model=os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile"), + messages=[ + {"role": "system", "content": sys_msg}, + {"role": "user", "content": "\n".join(transcript_lines)}, + ], + max_tokens=200, + temperature=0.4, + ) + text = (resp.choices[0].message.content or "").strip() + if not text: + _say("Не смог разобрать ответ.") + return + _say(text) + except Exception as e: + print(f"[conversation] LLM call failed: {e}") + _say("LLM не отвечает.") + + +def do_conversation_repeat(action=None, voice=None) -> None: + if _history is None: + _say("Я ещё ничего не говорил, сэр.") + return + last = next( + (m for m in reversed(_history) if m.get("role") == "assistant"), + None, + ) + if last is None: + _say("Я ещё ничего не говорил, сэр.") + return + _say(last.get("content") or "") + + +# ─── DuckDuckGo Instant Answer ────────────────────────────────────────────── + + +_DDG_TRIGGERS = [ + "что такое", "кто такой", "кто такая", + "расскажи про", "расскажи о", + "найди информацию о", "найди информацию про", + "поищи в интернете", "поищи в сети", + "погугли что такое", + "what is", "who is", "tell me about", "look up", "search the web for", +] + + +def _strip_ddg_trigger(phrase: str) -> str: + p = (phrase or "").lower().strip() + for t in _DDG_TRIGGERS: + if p.startswith(t): + return p[len(t):].strip() + return p + + +def do_ddg_answer(action=None, voice=None) -> None: + query = _strip_ddg_trigger(voice or "") + if not query: + _say("Что искать?") + return + url = ("https://api.duckduckgo.com/?q=" + + urllib.parse.quote(query, safe="") + + "&format=json&no_html=1&skip_disambig=1") + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 J.A.R.V.I.S."}) + with urllib.request.urlopen(req, timeout=8) as resp: + data = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError) as e: + print(f"[ddg] fetch failed: {e}") + _say("DDG не отвечает.") + return + + answer = (data.get("AbstractText") or "").strip() + if not answer: + answer = (data.get("Answer") or "").strip() + if not answer: + answer = (data.get("Definition") or "").strip() + if not answer: + related = data.get("RelatedTopics") or [] + for r in related: + t = (r or {}).get("Text", "").strip() + if t: + answer = t + break + if not answer: + fallback = "https://duckduckgo.com/?q=" + urllib.parse.quote(query, safe="") + try: + webbrowser.open(fallback) + except OSError: + pass + _say(f"Ничего конкретного не нашёл, открываю поиск по запросу: {query}") + return + + if len(answer) > 320: + answer = answer[:300] + "…" + _say(answer)