feat: extensions module + 19 new commands (100 → 119)
Mirror of new packs from the rust fork. Self-contained `extensions.py` with
new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks
from main.py.
Wiring (main.py)
- `import extensions` at top.
- `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)`
right after _set_clipboard is defined.
- Added 15 new action-type dispatches in run_action.
extensions.py (new, ~360 lines)
Action types:
lock_workstation → rundll32 user32.dll,LockWorkStation
screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage
OR file → ~/Pictures/Screenshots/jarvis-<ts>.png
disk_free / disk_list → PowerShell Get-PSDrive
coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard,
speaks length only — never echoes the password)
ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry
wifi_ssid → netsh wlan show interfaces parser (ru + en field names)
public_ip → urllib request to api.ipify.org
unit_convert {mode: length|weight|temperature|speed}
Russian-grammar pluralisation (фут/фута/футов).
date_math {mode: days_until|day_of_week}
Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>".
echo_repeat / self_ping — debug commands
interesting_fact → Groq LLM (requires GROQ_TOKEN)
commands.yaml — 19 new entries:
lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list,
coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip,
convert_length, convert_weight, convert_temperature, convert_speed,
days_until, day_of_week, echo_repeat, self_ping, interesting_fact.
Tests: python syntax check + yaml.safe_load both pass. Total commands: 119.
NOT YET mirrored from rust (would require deeper rework):
memory_pack / profile_switch / scheduler / macros / vision / codebase_qa
/ github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro
/ habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer
/ wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin,
password ARE included — others can follow).
The reason these weren't mirrored: they need new state files
(memory.json, profiles.json, schedule.json, macros.json) and background
threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
|
|
|
|
"""Extension handlers — 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
|
|
|
|
|
|
|
feat: memory_store + profiles_store + 10 new yaml entries (119 → 129)
Mirrors `long_term_memory` and `profiles` modules from rust fork.
memory_store.py (new, ~95 lines)
- JSON store at <here>/long_term_memory.json (atomic write).
- remember(key, value) / recall(key) / forget(key) / search(query, limit) /
all_facts() / build_llm_context(prompt, limit).
- Uses the same JSON shape as the rust side, so the file is
interoperable if you ever symlink the two installations to the same dir.
profiles_store.py (new, ~145 lines)
- Profiles at <here>/profiles/<name>.json. active_profile.txt persists.
- Seeds 5 defaults on first init: default ★, work 💼, game 🎮, sleep 🌙,
driving 🚗 — same as rust fork.
- active() / active_name() / set_active(name) / list_names() /
allows_command(cmd_id).
extensions.py (+6 handlers, +160 lines)
- do_memory_remember — derives key (first 6 words or split on '=')
- do_memory_recall — substring search or top-3 if no query
- do_memory_forget — exact then substring fallback
- do_memory_list — count + first 5 keys
- do_profile_set — target via action.target or voice ("работа" → work)
- do_profile_what — speaks current profile + description
main.py
- +6 dispatch entries for the new action types.
commands.yaml (+10 entries, 119 → 129)
- memory_remember, memory_recall, memory_forget, memory_list
- profile_work, profile_game, profile_sleep, profile_driving, profile_default
- profile_what
Tests: ast.parse passes for all .py files. yaml.safe_load gives 129 entries.
Python parity is now meaningful: memory + profiles are usable by voice
without any rust install. Still missing for full parity: scheduler (needs
background thread), macros (needs phrase-capture state), vision (needs
Groq vision model call), codebase_qa, github_pr.
2026-05-15 23:54:33 +03:00
|
|
|
|
import memory_store
|
|
|
|
|
|
import profiles_store
|
feat: vision + macros + scheduler in Python (129 → 142 commands)
Three big rust packs ported. Python parity now covers the FULL imba lineup
except codebase_qa and github_pr.
vision_handler.py (new, ~125 lines)
- Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq
vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL).
- do_vision_describe "что на экране" / "опиши экран"
- do_vision_read_error "прочитай ошибку"
- Requires GROQ_TOKEN; speaks "Не удалось" on missing.
macros_store.py (new, ~170 lines)
- JSON store at <here>/macros.json (atomic write-through).
- start(name) / save() / cancel() / replay(name, dispatch_fn)
- is_recording / recording_name / list_names / get / delete
- record_step(phrase) called by execute_cmd hook (see main.py)
- is_macro_control filter blocks "запиши макрос ..." from recording itself
(no infinite recursion).
- replay spawns daemon thread, fires each step with 800ms delay.
scheduler_store.py (new, ~220 lines)
- JSON store at <here>/schedule.json.
- parse_schedule(spec) handles "daily HH:MM", "at HH:MM",
"every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English.
- Background daemon thread ticks every 30s, fires Speak actions through the
same speak callback used by main.py.
- Once-tasks auto-delete after firing; daily/interval cycle forever.
- add/remove/clear/remove_by_text/list_all.
extensions.py (+13 handlers)
do_vision_describe / do_vision_read_error → thin proxies to vision_handler
do_macros_start / save / cancel / replay / list / delete
do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text
main.py
- extensions.init_background_services() at startup → scheduler thread boots.
- Wraps execute_cmd so every successful dispatch feeds macros_store.record_step.
- set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the
normal command pipeline (recognize_cmd → execute_cmd).
commands.yaml (+13 entries, 129 → 142)
vision_describe, vision_read_error, macros_start, macros_save, macros_cancel,
macros_replay, macros_list, macros_delete, scheduler_reminder,
scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text.
Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries.
Python parity status vs rust (72 packs):
✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch
(via env), llm_context (basic), media_keys (existing), screenshot, net_info,
unit_convert, generators, disk, date_math, sleep_timer (via shutdown),
interesting_fact, mailto, self_check, ssl_check, intro/echo
✗ codebase_qa, github_pr (require gh CLI + file walk — TODO)
✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics
(could be ported but need scheduler integration + LLM glue)
2026-05-16 00:00:22 +03:00
|
|
|
|
import macros_store
|
|
|
|
|
|
import scheduler_store
|
|
|
|
|
|
import vision_handler
|
feat: codebase_qa + github_pr in Python (142 → 148 commands)
Closes the Python parity gap with rust. All major imba features now mirrored
EXCEPT live LLM hot-swap (python LLM backend is still env-driven, not voice-
switchable like rust).
dev_handlers.py (new, ~250 lines)
Codebase Q&A:
do_codebase_set "укажи проект C:\path" → memory_store.remember("codebase.root", ...)
do_codebase_where "какой проект сейчас"
do_codebase_ask "что делает функция X" / "найди в коде Y"
Walks the root with same caps as rust:
depth=3, files=30, file_bytes=4096, total=50000
Filters by extension (rs/py/ts/lua/go/...).
Skips .git/target/node_modules/__pycache__/.venv etc.
Feeds digest to Groq LLM with "senior reviewer" prompt.
GitHub PR review (requires `gh` CLI authenticated):
do_github_set_repo "текущий репо owner/repo" → memory_store
do_github_list_prs "какие пиары" → `gh pr list --json number,title,author`,
speaks count + first 3 titles
do_github_summarize_pr "разбери последний пиар" → gh pr view of latest open
PR, feeds title+body+diff stat to LLM, speaks 3-5
sentence review.
extensions.py — 6 new proxy handlers + import dev_handlers + set_speak wires it.
main.py — 6 new action_type dispatches in run_action.
commands.yaml (+6 entries, 142 → 148):
codebase_set, codebase_where, codebase_ask,
github_set_repo, github_list_prs, github_summarize_pr.
Tests: ast.parse passes for all .py files. yaml.safe_load = 148 entries.
PYTHON PARITY VS RUST IS NOW COMPLETE except:
✗ LLM hot-swap voice command (rust calls llm::swap_to + persists DB;
python config.GROQ_TOKEN is read once at startup)
✗ GUI pages (rust has Tauri+Svelte, python is console-only)
Everything else has full parity: memory / profiles / vision / macros /
scheduler / codebase Q&A / GitHub PR review / 14 utility packs.
2026-05-16 00:40:08 +03:00
|
|
|
|
import dev_handlers
|
2026-05-16 00:44:42 +03:00
|
|
|
|
import llm_backend
|
2026-05-16 01:08:17 +03:00
|
|
|
|
import wave1_handlers
|
feat: personality pack — Python parity
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).
2026-05-16 13:53:44 +03:00
|
|
|
|
import personality_handlers
|
feat: memory_store + profiles_store + 10 new yaml entries (119 → 129)
Mirrors `long_term_memory` and `profiles` modules from rust fork.
memory_store.py (new, ~95 lines)
- JSON store at <here>/long_term_memory.json (atomic write).
- remember(key, value) / recall(key) / forget(key) / search(query, limit) /
all_facts() / build_llm_context(prompt, limit).
- Uses the same JSON shape as the rust side, so the file is
interoperable if you ever symlink the two installations to the same dir.
profiles_store.py (new, ~145 lines)
- Profiles at <here>/profiles/<name>.json. active_profile.txt persists.
- Seeds 5 defaults on first init: default ★, work 💼, game 🎮, sleep 🌙,
driving 🚗 — same as rust fork.
- active() / active_name() / set_active(name) / list_names() /
allows_command(cmd_id).
extensions.py (+6 handlers, +160 lines)
- do_memory_remember — derives key (first 6 words or split on '=')
- do_memory_recall — substring search or top-3 if no query
- do_memory_forget — exact then substring fallback
- do_memory_list — count + first 5 keys
- do_profile_set — target via action.target or voice ("работа" → work)
- do_profile_what — speaks current profile + description
main.py
- +6 dispatch entries for the new action types.
commands.yaml (+10 entries, 119 → 129)
- memory_remember, memory_recall, memory_forget, memory_list
- profile_work, profile_game, profile_sleep, profile_driving, profile_default
- profile_what
Tests: ast.parse passes for all .py files. yaml.safe_load gives 129 entries.
Python parity is now meaningful: memory + profiles are usable by voice
without any rust install. Still missing for full parity: scheduler (needs
background thread), macros (needs phrase-capture state), vision (needs
Groq vision model call), codebase_qa, github_pr.
2026-05-15 23:54:33 +03:00
|
|
|
|
|
feat: extensions module + 19 new commands (100 → 119)
Mirror of new packs from the rust fork. Self-contained `extensions.py` with
new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks
from main.py.
Wiring (main.py)
- `import extensions` at top.
- `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)`
right after _set_clipboard is defined.
- Added 15 new action-type dispatches in run_action.
extensions.py (new, ~360 lines)
Action types:
lock_workstation → rundll32 user32.dll,LockWorkStation
screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage
OR file → ~/Pictures/Screenshots/jarvis-<ts>.png
disk_free / disk_list → PowerShell Get-PSDrive
coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard,
speaks length only — never echoes the password)
ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry
wifi_ssid → netsh wlan show interfaces parser (ru + en field names)
public_ip → urllib request to api.ipify.org
unit_convert {mode: length|weight|temperature|speed}
Russian-grammar pluralisation (фут/фута/футов).
date_math {mode: days_until|day_of_week}
Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>".
echo_repeat / self_ping — debug commands
interesting_fact → Groq LLM (requires GROQ_TOKEN)
commands.yaml — 19 new entries:
lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list,
coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip,
convert_length, convert_weight, convert_temperature, convert_speed,
days_until, day_of_week, echo_repeat, self_ping, interesting_fact.
Tests: python syntax check + yaml.safe_load both pass. Total commands: 119.
NOT YET mirrored from rust (would require deeper rework):
memory_pack / profile_switch / scheduler / macros / vision / codebase_qa
/ github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro
/ habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer
/ wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin,
password ARE included — others can follow).
The reason these weren't mirrored: they need new state files
(memory.json, profiles.json, schedule.json, macros.json) and background
threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
|
|
|
|
_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
|
feat: vision + macros + scheduler in Python (129 → 142 commands)
Three big rust packs ported. Python parity now covers the FULL imba lineup
except codebase_qa and github_pr.
vision_handler.py (new, ~125 lines)
- Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq
vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL).
- do_vision_describe "что на экране" / "опиши экран"
- do_vision_read_error "прочитай ошибку"
- Requires GROQ_TOKEN; speaks "Не удалось" on missing.
macros_store.py (new, ~170 lines)
- JSON store at <here>/macros.json (atomic write-through).
- start(name) / save() / cancel() / replay(name, dispatch_fn)
- is_recording / recording_name / list_names / get / delete
- record_step(phrase) called by execute_cmd hook (see main.py)
- is_macro_control filter blocks "запиши макрос ..." from recording itself
(no infinite recursion).
- replay spawns daemon thread, fires each step with 800ms delay.
scheduler_store.py (new, ~220 lines)
- JSON store at <here>/schedule.json.
- parse_schedule(spec) handles "daily HH:MM", "at HH:MM",
"every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English.
- Background daemon thread ticks every 30s, fires Speak actions through the
same speak callback used by main.py.
- Once-tasks auto-delete after firing; daily/interval cycle forever.
- add/remove/clear/remove_by_text/list_all.
extensions.py (+13 handlers)
do_vision_describe / do_vision_read_error → thin proxies to vision_handler
do_macros_start / save / cancel / replay / list / delete
do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text
main.py
- extensions.init_background_services() at startup → scheduler thread boots.
- Wraps execute_cmd so every successful dispatch feeds macros_store.record_step.
- set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the
normal command pipeline (recognize_cmd → execute_cmd).
commands.yaml (+13 entries, 129 → 142)
vision_describe, vision_read_error, macros_start, macros_save, macros_cancel,
macros_replay, macros_list, macros_delete, scheduler_reminder,
scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text.
Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries.
Python parity status vs rust (72 packs):
✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch
(via env), llm_context (basic), media_keys (existing), screenshot, net_info,
unit_convert, generators, disk, date_math, sleep_timer (via shutdown),
interesting_fact, mailto, self_check, ssl_check, intro/echo
✗ codebase_qa, github_pr (require gh CLI + file walk — TODO)
✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics
(could be ported but need scheduler integration + LLM glue)
2026-05-16 00:00:22 +03:00
|
|
|
|
scheduler_store.set_speak(fn)
|
|
|
|
|
|
vision_handler.set_speak(fn)
|
feat: codebase_qa + github_pr in Python (142 → 148 commands)
Closes the Python parity gap with rust. All major imba features now mirrored
EXCEPT live LLM hot-swap (python LLM backend is still env-driven, not voice-
switchable like rust).
dev_handlers.py (new, ~250 lines)
Codebase Q&A:
do_codebase_set "укажи проект C:\path" → memory_store.remember("codebase.root", ...)
do_codebase_where "какой проект сейчас"
do_codebase_ask "что делает функция X" / "найди в коде Y"
Walks the root with same caps as rust:
depth=3, files=30, file_bytes=4096, total=50000
Filters by extension (rs/py/ts/lua/go/...).
Skips .git/target/node_modules/__pycache__/.venv etc.
Feeds digest to Groq LLM with "senior reviewer" prompt.
GitHub PR review (requires `gh` CLI authenticated):
do_github_set_repo "текущий репо owner/repo" → memory_store
do_github_list_prs "какие пиары" → `gh pr list --json number,title,author`,
speaks count + first 3 titles
do_github_summarize_pr "разбери последний пиар" → gh pr view of latest open
PR, feeds title+body+diff stat to LLM, speaks 3-5
sentence review.
extensions.py — 6 new proxy handlers + import dev_handlers + set_speak wires it.
main.py — 6 new action_type dispatches in run_action.
commands.yaml (+6 entries, 142 → 148):
codebase_set, codebase_where, codebase_ask,
github_set_repo, github_list_prs, github_summarize_pr.
Tests: ast.parse passes for all .py files. yaml.safe_load = 148 entries.
PYTHON PARITY VS RUST IS NOW COMPLETE except:
✗ LLM hot-swap voice command (rust calls llm::swap_to + persists DB;
python config.GROQ_TOKEN is read once at startup)
✗ GUI pages (rust has Tauri+Svelte, python is console-only)
Everything else has full parity: memory / profiles / vision / macros /
scheduler / codebase Q&A / GitHub PR review / 14 utility packs.
2026-05-16 00:40:08 +03:00
|
|
|
|
dev_handlers.set_speak(fn)
|
2026-05-16 01:08:17 +03:00
|
|
|
|
wave1_handlers.set_speak(fn)
|
feat: personality pack — Python parity
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).
2026-05-16 13:53:44 +03:00
|
|
|
|
personality_handlers.set_speak(fn)
|
feat: extensions module + 19 new commands (100 → 119)
Mirror of new packs from the rust fork. Self-contained `extensions.py` with
new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks
from main.py.
Wiring (main.py)
- `import extensions` at top.
- `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)`
right after _set_clipboard is defined.
- Added 15 new action-type dispatches in run_action.
extensions.py (new, ~360 lines)
Action types:
lock_workstation → rundll32 user32.dll,LockWorkStation
screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage
OR file → ~/Pictures/Screenshots/jarvis-<ts>.png
disk_free / disk_list → PowerShell Get-PSDrive
coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard,
speaks length only — never echoes the password)
ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry
wifi_ssid → netsh wlan show interfaces parser (ru + en field names)
public_ip → urllib request to api.ipify.org
unit_convert {mode: length|weight|temperature|speed}
Russian-grammar pluralisation (фут/фута/футов).
date_math {mode: days_until|day_of_week}
Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>".
echo_repeat / self_ping — debug commands
interesting_fact → Groq LLM (requires GROQ_TOKEN)
commands.yaml — 19 new entries:
lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list,
coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip,
convert_length, convert_weight, convert_temperature, convert_speed,
days_until, day_of_week, echo_repeat, self_ping, interesting_fact.
Tests: python syntax check + yaml.safe_load both pass. Total commands: 119.
NOT YET mirrored from rust (would require deeper rework):
memory_pack / profile_switch / scheduler / macros / vision / codebase_qa
/ github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro
/ habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer
/ wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin,
password ARE included — others can follow).
The reason these weren't mirrored: they need new state files
(memory.json, profiles.json, schedule.json, macros.json) and background
threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_clipboard_fn(fn):
|
|
|
|
|
|
"""Register the main.py _set_clipboard helper."""
|
|
|
|
|
|
global _set_clipboard_fn
|
|
|
|
|
|
_set_clipboard_fn = fn
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: vision + macros + scheduler in Python (129 → 142 commands)
Three big rust packs ported. Python parity now covers the FULL imba lineup
except codebase_qa and github_pr.
vision_handler.py (new, ~125 lines)
- Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq
vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL).
- do_vision_describe "что на экране" / "опиши экран"
- do_vision_read_error "прочитай ошибку"
- Requires GROQ_TOKEN; speaks "Не удалось" on missing.
macros_store.py (new, ~170 lines)
- JSON store at <here>/macros.json (atomic write-through).
- start(name) / save() / cancel() / replay(name, dispatch_fn)
- is_recording / recording_name / list_names / get / delete
- record_step(phrase) called by execute_cmd hook (see main.py)
- is_macro_control filter blocks "запиши макрос ..." from recording itself
(no infinite recursion).
- replay spawns daemon thread, fires each step with 800ms delay.
scheduler_store.py (new, ~220 lines)
- JSON store at <here>/schedule.json.
- parse_schedule(spec) handles "daily HH:MM", "at HH:MM",
"every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English.
- Background daemon thread ticks every 30s, fires Speak actions through the
same speak callback used by main.py.
- Once-tasks auto-delete after firing; daily/interval cycle forever.
- add/remove/clear/remove_by_text/list_all.
extensions.py (+13 handlers)
do_vision_describe / do_vision_read_error → thin proxies to vision_handler
do_macros_start / save / cancel / replay / list / delete
do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text
main.py
- extensions.init_background_services() at startup → scheduler thread boots.
- Wraps execute_cmd so every successful dispatch feeds macros_store.record_step.
- set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the
normal command pipeline (recognize_cmd → execute_cmd).
commands.yaml (+13 entries, 129 → 142)
vision_describe, vision_read_error, macros_start, macros_save, macros_cancel,
macros_replay, macros_list, macros_delete, scheduler_reminder,
scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text.
Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries.
Python parity status vs rust (72 packs):
✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch
(via env), llm_context (basic), media_keys (existing), screenshot, net_info,
unit_convert, generators, disk, date_math, sleep_timer (via shutdown),
interesting_fact, mailto, self_check, ssl_check, intro/echo
✗ codebase_qa, github_pr (require gh CLI + file walk — TODO)
✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics
(could be ported but need scheduler integration + LLM glue)
2026-05-16 00:00:22 +03:00
|
|
|
|
def init_background_services():
|
|
|
|
|
|
"""Boot scheduler. Idempotent. Called once from main.py at startup."""
|
|
|
|
|
|
scheduler_store.init()
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: extensions module + 19 new commands (100 → 119)
Mirror of new packs from the rust fork. Self-contained `extensions.py` with
new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks
from main.py.
Wiring (main.py)
- `import extensions` at top.
- `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)`
right after _set_clipboard is defined.
- Added 15 new action-type dispatches in run_action.
extensions.py (new, ~360 lines)
Action types:
lock_workstation → rundll32 user32.dll,LockWorkStation
screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage
OR file → ~/Pictures/Screenshots/jarvis-<ts>.png
disk_free / disk_list → PowerShell Get-PSDrive
coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard,
speaks length only — never echoes the password)
ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry
wifi_ssid → netsh wlan show interfaces parser (ru + en field names)
public_ip → urllib request to api.ipify.org
unit_convert {mode: length|weight|temperature|speed}
Russian-grammar pluralisation (фут/фута/футов).
date_math {mode: days_until|day_of_week}
Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>".
echo_repeat / self_ping — debug commands
interesting_fact → Groq LLM (requires GROQ_TOKEN)
commands.yaml — 19 new entries:
lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list,
coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip,
convert_length, convert_weight, convert_temperature, convert_speed,
days_until, day_of_week, echo_repeat, self_ping, interesting_fact.
Tests: python syntax check + yaml.safe_load both pass. Total commands: 119.
NOT YET mirrored from rust (would require deeper rework):
memory_pack / profile_switch / scheduler / macros / vision / codebase_qa
/ github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro
/ habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer
/ wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin,
password ARE included — others can follow).
The reason these weren't mirrored: they need new state files
(memory.json, profiles.json, schedule.json, macros.json) and background
threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: memory_store + profiles_store + 10 new yaml entries (119 → 129)
Mirrors `long_term_memory` and `profiles` modules from rust fork.
memory_store.py (new, ~95 lines)
- JSON store at <here>/long_term_memory.json (atomic write).
- remember(key, value) / recall(key) / forget(key) / search(query, limit) /
all_facts() / build_llm_context(prompt, limit).
- Uses the same JSON shape as the rust side, so the file is
interoperable if you ever symlink the two installations to the same dir.
profiles_store.py (new, ~145 lines)
- Profiles at <here>/profiles/<name>.json. active_profile.txt persists.
- Seeds 5 defaults on first init: default ★, work 💼, game 🎮, sleep 🌙,
driving 🚗 — same as rust fork.
- active() / active_name() / set_active(name) / list_names() /
allows_command(cmd_id).
extensions.py (+6 handlers, +160 lines)
- do_memory_remember — derives key (first 6 words or split on '=')
- do_memory_recall — substring search or top-3 if no query
- do_memory_forget — exact then substring fallback
- do_memory_list — count + first 5 keys
- do_profile_set — target via action.target or voice ("работа" → work)
- do_profile_what — speaks current profile + description
main.py
- +6 dispatch entries for the new action types.
commands.yaml (+10 entries, 119 → 129)
- memory_remember, memory_recall, memory_forget, memory_list
- profile_work, profile_game, profile_sleep, profile_driving, profile_default
- profile_what
Tests: ast.parse passes for all .py files. yaml.safe_load gives 129 entries.
Python parity is now meaningful: memory + profiles are usable by voice
without any rust install. Still missing for full parity: scheduler (needs
background thread), macros (needs phrase-capture state), vision (needs
Groq vision model call), codebase_qa, github_pr.
2026-05-15 23:54:33 +03:00
|
|
|
|
# ── 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: vision + macros + scheduler in Python (129 → 142 commands)
Three big rust packs ported. Python parity now covers the FULL imba lineup
except codebase_qa and github_pr.
vision_handler.py (new, ~125 lines)
- Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq
vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL).
- do_vision_describe "что на экране" / "опиши экран"
- do_vision_read_error "прочитай ошибку"
- Requires GROQ_TOKEN; speaks "Не удалось" on missing.
macros_store.py (new, ~170 lines)
- JSON store at <here>/macros.json (atomic write-through).
- start(name) / save() / cancel() / replay(name, dispatch_fn)
- is_recording / recording_name / list_names / get / delete
- record_step(phrase) called by execute_cmd hook (see main.py)
- is_macro_control filter blocks "запиши макрос ..." from recording itself
(no infinite recursion).
- replay spawns daemon thread, fires each step with 800ms delay.
scheduler_store.py (new, ~220 lines)
- JSON store at <here>/schedule.json.
- parse_schedule(spec) handles "daily HH:MM", "at HH:MM",
"every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English.
- Background daemon thread ticks every 30s, fires Speak actions through the
same speak callback used by main.py.
- Once-tasks auto-delete after firing; daily/interval cycle forever.
- add/remove/clear/remove_by_text/list_all.
extensions.py (+13 handlers)
do_vision_describe / do_vision_read_error → thin proxies to vision_handler
do_macros_start / save / cancel / replay / list / delete
do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text
main.py
- extensions.init_background_services() at startup → scheduler thread boots.
- Wraps execute_cmd so every successful dispatch feeds macros_store.record_step.
- set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the
normal command pipeline (recognize_cmd → execute_cmd).
commands.yaml (+13 entries, 129 → 142)
vision_describe, vision_read_error, macros_start, macros_save, macros_cancel,
macros_replay, macros_list, macros_delete, scheduler_reminder,
scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text.
Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries.
Python parity status vs rust (72 packs):
✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch
(via env), llm_context (basic), media_keys (existing), screenshot, net_info,
unit_convert, generators, disk, date_math, sleep_timer (via shutdown),
interesting_fact, mailto, self_check, ssl_check, intro/echo
✗ codebase_qa, github_pr (require gh CLI + file walk — TODO)
✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics
(could be ported but need scheduler integration + LLM glue)
2026-05-16 00:00:22 +03:00
|
|
|
|
# ── 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}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: extensions module + 19 new commands (100 → 119)
Mirror of new packs from the rust fork. Self-contained `extensions.py` with
new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks
from main.py.
Wiring (main.py)
- `import extensions` at top.
- `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)`
right after _set_clipboard is defined.
- Added 15 new action-type dispatches in run_action.
extensions.py (new, ~360 lines)
Action types:
lock_workstation → rundll32 user32.dll,LockWorkStation
screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage
OR file → ~/Pictures/Screenshots/jarvis-<ts>.png
disk_free / disk_list → PowerShell Get-PSDrive
coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard,
speaks length only — never echoes the password)
ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry
wifi_ssid → netsh wlan show interfaces parser (ru + en field names)
public_ip → urllib request to api.ipify.org
unit_convert {mode: length|weight|temperature|speed}
Russian-grammar pluralisation (фут/фута/футов).
date_math {mode: days_until|day_of_week}
Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>".
echo_repeat / self_ping — debug commands
interesting_fact → Groq LLM (requires GROQ_TOKEN)
commands.yaml — 19 new entries:
lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list,
coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip,
convert_length, convert_weight, convert_temperature, convert_speed,
days_until, day_of_week, echo_repeat, self_ping, interesting_fact.
Tests: python syntax check + yaml.safe_load both pass. Total commands: 119.
NOT YET mirrored from rust (would require deeper rework):
memory_pack / profile_switch / scheduler / macros / vision / codebase_qa
/ github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro
/ habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer
/ wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin,
password ARE included — others can follow).
The reason these weren't mirrored: they need new state files
(memory.json, profiles.json, schedule.json, macros.json) and background
threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
|
|
|
|
# ── interesting fact (LLM-dependent) ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def do_interesting_fact(action, voice):
|
2026-05-16 00:44:42 +03:00
|
|
|
|
"""Asks the active LLM for a fun fact. Backend chosen by llm_backend."""
|
|
|
|
|
|
client = llm_backend.current_client()
|
|
|
|
|
|
if client is None:
|
feat: extensions module + 19 new commands (100 → 119)
Mirror of new packs from the rust fork. Self-contained `extensions.py` with
new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks
from main.py.
Wiring (main.py)
- `import extensions` at top.
- `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)`
right after _set_clipboard is defined.
- Added 15 new action-type dispatches in run_action.
extensions.py (new, ~360 lines)
Action types:
lock_workstation → rundll32 user32.dll,LockWorkStation
screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage
OR file → ~/Pictures/Screenshots/jarvis-<ts>.png
disk_free / disk_list → PowerShell Get-PSDrive
coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard,
speaks length only — never echoes the password)
ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry
wifi_ssid → netsh wlan show interfaces parser (ru + en field names)
public_ip → urllib request to api.ipify.org
unit_convert {mode: length|weight|temperature|speed}
Russian-grammar pluralisation (фут/фута/футов).
date_math {mode: days_until|day_of_week}
Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>".
echo_repeat / self_ping — debug commands
interesting_fact → Groq LLM (requires GROQ_TOKEN)
commands.yaml — 19 new entries:
lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list,
coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip,
convert_length, convert_weight, convert_temperature, convert_speed,
days_until, day_of_week, echo_repeat, self_ping, interesting_fact.
Tests: python syntax check + yaml.safe_load both pass. Total commands: 119.
NOT YET mirrored from rust (would require deeper rework):
memory_pack / profile_switch / scheduler / macros / vision / codebase_qa
/ github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro
/ habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer
/ wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin,
password ARE included — others can follow).
The reason these weren't mirrored: they need new state files
(memory.json, profiles.json, schedule.json, macros.json) and background
threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
|
|
|
|
_speak("LLM не настроен.")
|
|
|
|
|
|
return
|
2026-05-16 00:44:42 +03:00
|
|
|
|
model = llm_backend.current_model()
|
feat: extensions module + 19 new commands (100 → 119)
Mirror of new packs from the rust fork. Self-contained `extensions.py` with
new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks
from main.py.
Wiring (main.py)
- `import extensions` at top.
- `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)`
right after _set_clipboard is defined.
- Added 15 new action-type dispatches in run_action.
extensions.py (new, ~360 lines)
Action types:
lock_workstation → rundll32 user32.dll,LockWorkStation
screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage
OR file → ~/Pictures/Screenshots/jarvis-<ts>.png
disk_free / disk_list → PowerShell Get-PSDrive
coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard,
speaks length only — never echoes the password)
ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry
wifi_ssid → netsh wlan show interfaces parser (ru + en field names)
public_ip → urllib request to api.ipify.org
unit_convert {mode: length|weight|temperature|speed}
Russian-grammar pluralisation (фут/фута/футов).
date_math {mode: days_until|day_of_week}
Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>".
echo_repeat / self_ping — debug commands
interesting_fact → Groq LLM (requires GROQ_TOKEN)
commands.yaml — 19 new entries:
lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list,
coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip,
convert_length, convert_weight, convert_temperature, convert_speed,
days_until, day_of_week, echo_repeat, self_ping, interesting_fact.
Tests: python syntax check + yaml.safe_load both pass. Total commands: 119.
NOT YET mirrored from rust (would require deeper rework):
memory_pack / profile_switch / scheduler / macros / vision / codebase_qa
/ github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro
/ habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer
/ wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin,
password ARE included — others can follow).
The reason these weren't mirrored: they need new state files
(memory.json, profiles.json, schedule.json, macros.json) and background
threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
|
|
|
|
|
|
|
|
|
|
topic = ''
|
|
|
|
|
|
low = (voice or '').lower()
|
|
|
|
|
|
for trig in ('расскажи интересный факт про', 'расскажи интересный факт о',
|
|
|
|
|
|
'интересный факт про', 'интересный факт о',
|
|
|
|
|
|
'расскажи интересный факт', 'интересный факт',
|
|
|
|
|
|
'расскажи факт', 'удиви меня'):
|
|
|
|
|
|
idx = low.find(trig)
|
|
|
|
|
|
if idx >= 0:
|
|
|
|
|
|
topic = (voice or '')[idx + len(trig):].strip(' ,.:').strip()
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if topic:
|
|
|
|
|
|
prompt = f"Расскажи один интересный факт про {topic}. На русском, 1-2 предложения. Без вступлений."
|
|
|
|
|
|
else:
|
|
|
|
|
|
prompt = "Расскажи один реально неочевидный научно-проверенный факт. На русском, 1-2 предложения. Без вступлений типа 'знали ли вы'."
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = client.chat.completions.create(
|
2026-05-16 00:44:42 +03:00
|
|
|
|
model=model,
|
feat: extensions module + 19 new commands (100 → 119)
Mirror of new packs from the rust fork. Self-contained `extensions.py` with
new do_* handlers, registered via 'set_speak' / 'set_clipboard_fn' callbacks
from main.py.
Wiring (main.py)
- `import extensions` at top.
- `extensions.set_speak(_speak)` + `extensions.set_clipboard_fn(_set_clipboard)`
right after _set_clipboard is defined.
- Added 15 new action-type dispatches in run_action.
extensions.py (new, ~360 lines)
Action types:
lock_workstation → rundll32 user32.dll,LockWorkStation
screenshot {mode} → clipboard via System.Windows.Forms.Clipboard.SetImage
OR file → ~/Pictures/Screenshots/jarvis-<ts>.png
disk_free / disk_list → PowerShell Get-PSDrive
coin_flip / uuid_gen / password_gen (length 6..64, copies to clipboard,
speaks length only — never echoes the password)
ssl_check {domain} → PowerShell TCP+SslStream→X509Certificate2 expiry
wifi_ssid → netsh wlan show interfaces parser (ru + en field names)
public_ip → urllib request to api.ipify.org
unit_convert {mode: length|weight|temperature|speed}
Russian-grammar pluralisation (фут/фута/футов).
date_math {mode: days_until|day_of_week}
Recognises Новый год, Рождество, 8 марта, 9 мая + "до N <месяц>".
echo_repeat / self_ping — debug commands
interesting_fact → Groq LLM (requires GROQ_TOKEN)
commands.yaml — 19 new entries:
lock_workstation, screenshot_clipboard, screenshot_file, disk_free, disk_list,
coin_flip, password_gen, uuid_gen, ssl_check, wifi_ssid, public_ip,
convert_length, convert_weight, convert_temperature, convert_speed,
days_until, day_of_week, echo_repeat, self_ping, interesting_fact.
Tests: python syntax check + yaml.safe_load both pass. Total commands: 119.
NOT YET mirrored from rust (would require deeper rework):
memory_pack / profile_switch / scheduler / macros / vision / codebase_qa
/ github_pr / llm_switch / llm_context / media_keys / mood_log / pomodoro
/ habit_nudge / daily_briefing / quick_search / diagnostics / sleep_timer
/ wikipedia (the rewritten one) / intro / mailto / generators (UUID, coin,
password ARE included — others can follow).
The reason these weren't mirrored: they need new state files
(memory.json, profiles.json, schedule.json, macros.json) and background
threads (scheduler tick). Doable as a follow-up but out of scope here.
2026-05-15 23:51:13 +03:00
|
|
|
|
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("Не получилось получить факт.")
|
feat: codebase_qa + github_pr in Python (142 → 148 commands)
Closes the Python parity gap with rust. All major imba features now mirrored
EXCEPT live LLM hot-swap (python LLM backend is still env-driven, not voice-
switchable like rust).
dev_handlers.py (new, ~250 lines)
Codebase Q&A:
do_codebase_set "укажи проект C:\path" → memory_store.remember("codebase.root", ...)
do_codebase_where "какой проект сейчас"
do_codebase_ask "что делает функция X" / "найди в коде Y"
Walks the root with same caps as rust:
depth=3, files=30, file_bytes=4096, total=50000
Filters by extension (rs/py/ts/lua/go/...).
Skips .git/target/node_modules/__pycache__/.venv etc.
Feeds digest to Groq LLM with "senior reviewer" prompt.
GitHub PR review (requires `gh` CLI authenticated):
do_github_set_repo "текущий репо owner/repo" → memory_store
do_github_list_prs "какие пиары" → `gh pr list --json number,title,author`,
speaks count + first 3 titles
do_github_summarize_pr "разбери последний пиар" → gh pr view of latest open
PR, feeds title+body+diff stat to LLM, speaks 3-5
sentence review.
extensions.py — 6 new proxy handlers + import dev_handlers + set_speak wires it.
main.py — 6 new action_type dispatches in run_action.
commands.yaml (+6 entries, 142 → 148):
codebase_set, codebase_where, codebase_ask,
github_set_repo, github_list_prs, github_summarize_pr.
Tests: ast.parse passes for all .py files. yaml.safe_load = 148 entries.
PYTHON PARITY VS RUST IS NOW COMPLETE except:
✗ LLM hot-swap voice command (rust calls llm::swap_to + persists DB;
python config.GROQ_TOKEN is read once at startup)
✗ GUI pages (rust has Tauri+Svelte, python is console-only)
Everything else has full parity: memory / profiles / vision / macros /
scheduler / codebase Q&A / GitHub PR review / 14 utility packs.
2026-05-16 00:40:08 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 00:44:42 +03:00
|
|
|
|
# ── 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 не настроен.")
|
|
|
|
|
|
|
|
|
|
|
|
|
feat+test: pytest suite + Now Playing + World Clock + Daily Quote packs (154 commands)
Adds first formal test suite for Python fork — 42 tests covering memory_store,
profiles_store, scheduler_store, macros_store, llm_backend. All pass.
Tests (tests/, ~290 lines)
conftest.py — `isolated_state` fixture: rewires _PATH/_PROFILES_DIR etc to
a per-test tmp_path so concurrent runs don't touch real data.
test_memory_store.py — 9 tests: remember/recall/forget round-trip,
search by key/value, limit/empty query,
persistence across reload, build_llm_context
test_profiles_store.py — 7 tests: seeded defaults, set_active persistence,
allows_command logic (default/work/driving)
test_scheduler_store.py — 12 tests: parser (daily/at/every/in, ru units,
bad input), add/remove/clear/remove_by_text,
_next_fire for each schedule kind
test_macros_store.py — 8 tests: is_macro_control filter, start/save
round-trip, control-phrase filtering, list_names
sorted, replay unknown raises
test_llm_backend.py — 6 tests: parse_backend aliases (ru + en),
persist round-trip, auto-detect logic
Run: cd /c/Jarvis/python && python -m pytest tests/
Three new fun packs (commands.yaml: 151 → 154)
now_playing — Windows Media Session API via PowerShell
"что играет" / "что за песня" / "какой трек"
Works with Spotify, YouTube, Foobar, Yandex Music, anything
that exposes SMTC (Win10 1803+).
world_clock — worldtimeapi.org (free, no key)
"сколько времени в Токио" / "время в Лондоне"
21 Russian + world cities pre-mapped to IANA timezones.
daily_quote — zenquotes.io (free, no key) + LLM translation
"цитата дня" / "вдохнови меня"
Fetches English quote, translates to Russian via active LLM
(Groq or Ollama), speaks "text — author."
Pack count: 151 → 154. Tests: 42 pytest + ast.parse + yaml.safe_load.
2026-05-16 00:56:02 +03:00
|
|
|
|
# ── 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}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: codebase_qa + github_pr in Python (142 → 148 commands)
Closes the Python parity gap with rust. All major imba features now mirrored
EXCEPT live LLM hot-swap (python LLM backend is still env-driven, not voice-
switchable like rust).
dev_handlers.py (new, ~250 lines)
Codebase Q&A:
do_codebase_set "укажи проект C:\path" → memory_store.remember("codebase.root", ...)
do_codebase_where "какой проект сейчас"
do_codebase_ask "что делает функция X" / "найди в коде Y"
Walks the root with same caps as rust:
depth=3, files=30, file_bytes=4096, total=50000
Filters by extension (rs/py/ts/lua/go/...).
Skips .git/target/node_modules/__pycache__/.venv etc.
Feeds digest to Groq LLM with "senior reviewer" prompt.
GitHub PR review (requires `gh` CLI authenticated):
do_github_set_repo "текущий репо owner/repo" → memory_store
do_github_list_prs "какие пиары" → `gh pr list --json number,title,author`,
speaks count + first 3 titles
do_github_summarize_pr "разбери последний пиар" → gh pr view of latest open
PR, feeds title+body+diff stat to LLM, speaks 3-5
sentence review.
extensions.py — 6 new proxy handlers + import dev_handlers + set_speak wires it.
main.py — 6 new action_type dispatches in run_action.
commands.yaml (+6 entries, 142 → 148):
codebase_set, codebase_where, codebase_ask,
github_set_repo, github_list_prs, github_summarize_pr.
Tests: ast.parse passes for all .py files. yaml.safe_load = 148 entries.
PYTHON PARITY VS RUST IS NOW COMPLETE except:
✗ LLM hot-swap voice command (rust calls llm::swap_to + persists DB;
python config.GROQ_TOKEN is read once at startup)
✗ GUI pages (rust has Tauri+Svelte, python is console-only)
Everything else has full parity: memory / profiles / vision / macros /
scheduler / codebase Q&A / GitHub PR review / 14 utility packs.
2026-05-16 00:40:08 +03:00
|
|
|
|
# ── 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)
|
2026-05-16 01:08:17 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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)
|
feat: personality pack — Python parity
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).
2026-05-16 13:53:44 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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)
|