Compare commits

...
Sign in to create a new pull request.

24 commits

Author SHA1 Message Date
Bossiara13
633850d0f7 feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests
Some checks failed
Python CI / pytest (ubuntu-latest) (push) Failing after 5s
Python CI / ruff (push) Failing after 5s
Python CI / pytest (windows-latest) (push) Has been cancelled
New `wave59_handlers.py` (~250 lines) mirroring four Rust-only packs:

- wol: pure-Python magic packet via socket + SO_BROADCAST. No PowerShell
  shellout (unlike the Rust pack). Reads MAC from memory_store under
  'wol_<alias>' or JARVIS_WOL_TARGET env. Same MAC format tolerance
  (colons / dashes / dots / bare) as the Rust normaliser.

- banter: pause/resume state + fire-one. Python edition doesn't run a
  background banter thread (the Rust daemon already does), so this is
  just the user-facing surface — same voice phrases work.

- conversation: do_conversation_summary calls Groq with last 12 turns
  from main.py's `message_log`. set_history_ref() injects the live list
  reference at startup so the handler reads up-to-date history without
  a circular import. Offline-safe: GROQ_TOKEN missing → speak the last
  user message instead. do_conversation_repeat re-speaks the last
  assistant turn.

- ddg_answer: urllib + DuckDuckGo Instant Answer JSON. Picks
  AbstractText → Answer → Definition → RelatedTopics[0]. Opens
  duckduckgo.com search fallback when nothing instant.

extensions.py / main.py / commands.yaml: 7 new dispatch entries +
proxy fns. commands.yaml now at 207 entries.

tests/test_wave59_handlers.py: 11 unit tests for MAC normalisation,
magic packet shape, WOL error paths, banter state machine, DDG trigger
stripping, conversation history handling. Network calls deliberately
NOT exercised here — they belong to integration tests.

Tests: 104 → 115 (+11).
2026-05-24 23:12:04 +03:00
Bossiara13
cdd331af35 fix: Python startup no longer silent on first run
Some checks failed
Python CI / pytest (ubuntu-latest) (push) Failing after 27s
Python CI / ruff (push) Failing after 5s
Python CI / pytest (windows-latest) (push) Has been cancelled
ROOT CAUSE

User reported the Python edition showed "только пустой терминал" — empty
terminal — when launched. Three compounding issues:

1. `tts.py` called `torch.hub.load(...)` at MODULE IMPORT time. On first
   use this downloads a 60MB Silero model with no progress output beyond
   one "Using cache found in..." line. With no prior banner the user
   couldn't tell if it crashed or was loading.
2. main.py's startup banner (`print("=" * 60)...J.A.R.V.I.S. v...`)
   appeared AFTER all the heavy imports. So even when nothing was wrong,
   visible feedback was delayed by 10-60 seconds.
3. If `pip install -r requirements.txt` failed for any package
   (`simpleaudio` notoriously needs MSVC to build), `import simpleaudio`
   crashed the whole process with a raw ModuleNotFoundError.

FIXES

- `tts.py`: Silero model load moved into `_ensure_model()` called lazily
  from `synthesize()`. First synth pays the load cost (with a one-line
  progress hint) — everything before that is visible immediately.
- `main.py`: print "J.A.R.V.I.S. loading..." banner BEFORE any heavy
  imports. User sees the terminal is alive within ~100ms.
- `main.py`: scan required deps before importing them; if any are
  missing, print "Run: pip install -r requirements.txt" with the
  specific list and exit cleanly (exit code 2) instead of stack-tracing.
- `main.py`: `simpleaudio` is now soft-optional. Falls back to stdlib
  `winsound` on Windows installs that couldn't build the C extension.
  Sound cues still work; only the precise wait_done semantics are
  slightly different.

VERIFIED LOCALLY

`.venv\Scripts\python.exe main.py` now prints:
```
============================================================
  J.A.R.V.I.S.  loading (Python edition)...
  Heavy modules (vosk / torch / pvrecorder) take a few seconds.
============================================================
[scheduler] thread started (1 tasks loaded)
============================================================
  J.A.R.V.I.S.  v0.4.1   [Python edition]
  Total commands: 200
============================================================
Using device: Микрофон (5- Fifine Microphone)
Jarvis (v0.4.1) начал свою работу ...
Yes, sir.
```

Total time to first banner: ~100ms (was 10-60s of nothing).
All 104 pytest tests still pass.
2026-05-24 22:12:24 +03:00
Bossiara13
f102b8dc95 feat: expense tracker Python parity (Wave 10)
Some checks failed
Python CI / pytest (ubuntu-latest) (push) Has been cancelled
Python CI / pytest (windows-latest) (push) Has been cancelled
Python CI / ruff (push) Has been cancelled
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.
2026-05-16 15:02:07 +03:00
Bossiara13
b3cfaa5171 chore: gitignore active_profile.txt + time_tracker.json (runtime state)
Some checks are pending
Python CI / pytest (ubuntu-latest) (push) Waiting to run
Python CI / pytest (windows-latest) (push) Waiting to run
Python CI / ruff (push) Waiting to run
2026-05-16 14:56:05 +03:00
Bossiara13
94edb73f26 feat: Python parity for Wave 6-8 — memory admin / cooking / leftoff / trivia / routines
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).
2026-05-16 14:55:47 +03:00
Bossiara13
5265cb0fbe feat: Wave 5 Python parity — outlook COM + time tracker
Some checks are pending
Python CI / pytest (ubuntu-latest) (push) Waiting to run
Python CI / pytest (windows-latest) (push) Waiting to run
Python CI / ruff (push) Waiting to run
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
  `<here>/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).
2026-05-16 14:34:44 +03:00
Bossiara13
45d9425ed7 feat: personality pack — Python parity
Some checks are pending
Python CI / pytest (ubuntu-latest) (push) Waiting to run
Python CI / pytest (windows-latest) (push) Waiting to run
Python CI / ruff (push) Waiting to run
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
Bossiara13
6079d2730f chore: GitHub Actions CI workflow
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.
2026-05-16 13:38:22 +03:00
Bossiara13
59a1258dcc feat: Wave 3 — plugin loader + Flet GUI (parity with Rust fork)
Plugin loader (mirrors Rust #29):
- plugins_store.py walks %APPDATA%\com.priler.jarvis\plugins\<name>\
  for commands.yaml fragments and merges them into VA_CMD_LIST at
  startup. Same schema as the project root commands.yaml. Per-pack
  `disabled` flag file skips a pack without deletion. Built-in ids
  win conflicts so a malicious pack can't silently override
  open_browser. 9 unit tests (parity with the Rust tests).
- main.py: plugin merge runs after the root yaml load; logs the
  number of extra commands picked up.

Flet GUI (#27 — first parity with the Rust Tauri GUI):
- New gui/ package: __main__.py launches via `python -m gui` (or
  gui.bat). app.py routes between 7 pages (Главная / Команды /
  Макросы / Расписание / Память / Плагины / Настройки) via
  NavigationRail; dark theme matching the Tauri look.
- gui/services.py centralises every store-module call the pages
  share — single place to swap implementations later.
- One file per page in gui/pages/, all expose `build(page)` and use
  the same card layout idiom. Pages mirror the Tauri ones feature-
  for-feature: home (status + counters + launcher), commands
  (filterable list of all VA_CMD_LIST entries), macros (list +
  delete + recording banner), scheduler (list + remove), memory
  (add/forget/search), plugins (toggle/open folder), settings
  (profile + LLM hot-swap).
- requirements.txt: flet>=0.85 (optional; assistant runs without).
- 3 smoke tests verify imports + build callables; skip cleanly when
  Flet isn't installed.

README: documents both the GUI and the plugin layout in user-facing
terms. Tests: 42 → 54 (+9 plugins +3 GUI smoke).
2026-05-16 13:27:04 +03:00
Bossiara13
2bef1d5ea7 feat: Wave 1 python parity — 20 new commands (154 → 174)
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 <title> 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.
2026-05-16 01:08:17 +03:00
Bossiara13
4e62049a98 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
Bossiara13
8d3da4ea06 feat: LLM hot-swap voice command + Ollama backend (148 → 151 commands)
Closes the last functional parity gap with rust. Python now has voice-driven
Ollama↔Groq switching, persistent across restarts.

llm_backend.py (new, ~130 lines)
  Singleton module:
    current_client()     → active OpenAI client (or None)
    current_backend()    → 'groq' | 'ollama' | 'none'
    current_model()      → active model name
    swap_to(name)        → hot-swap + persist to <here>/llm_backend.txt
    parse_backend(name)  → ru/en alias normaliser ('облако'→groq, 'локальный'→ollama)

  Backends:
    - Groq:   config.GROQ_TOKEN + GROQ_BASE_URL + GROQ_MODEL (defaults)
    - Ollama: OLLAMA_BASE_URL (default http://localhost:11434/v1) +
              OLLAMA_MODEL (default qwen2.5:3b). api_key='ollama' (placeholder,
              openai lib insists on a non-empty string; Ollama ignores it).

  Init precedence (idempotent _ensure_init):
    1. Persisted choice from llm_backend.txt
    2. JARVIS_LLM env var
    3. Auto-detect: Groq if token present, else Ollama

Voice commands (commands.yaml, +3 entries → 151)
  llm_switch_local  → ollama
  llm_switch_cloud  → groq
  llm_status        → speaks current backend

extensions.py
  + do_llm_switch / do_llm_status handlers
  + llm_backend import
  do_interesting_fact migrated off direct OpenAI(...) to use llm_backend.current_*

dev_handlers.py
  + llm_backend import
  do_codebase_ask + do_github_summarize_pr migrated to llm_backend.current_*

vision_handler.py
  Vision call documented as Groq-specific (Ollama doesn't expose vision via
  OpenAI-compat in our stack), kept direct config.GROQ_TOKEN reading.

Tests: ast.parse passes for all 5 modules. yaml.safe_load = 151 entries.

PYTHON PARITY VS RUST IS NOW FUNCTIONALLY COMPLETE.
Only remaining rust-only feature: the Tauri GUI (python is console-only).
2026-05-16 00:44:42 +03:00
Bossiara13
31ff997782 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
Bossiara13
6a328077bc 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
Bossiara13
02c6f2f90d 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
Bossiara13
f82427c7b6 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
Bossiara13
ef8da9cba8 feat: parity — brightness, window switch, spelling, network, summarize (100 commands)
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.
2026-05-15 13:11:37 +03:00
Bossiara13
b3a5bcabe5 feat: parity — games, mouse, random_choice + new packs (90 commands)
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/<appid> (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
2026-05-15 12:58:32 +03:00
Bossiara13
40a46567b8 feat: parity — news, currency, crypto, fun, wiki, power, help (82 commands)
Mirrors the rust analog-inspired tier-2 packs. yaml + ast smoke pass.
56 → 82 commands (+26 yaml entries; 14 of them are power/fun/wiki/
news/etc. permutations).

main.py — 7 new action handlers, all built on stdlib urllib.request
(no new dep). Helper _http_get() with User-Agent header so RSS feeds
that 403 default UAs go through.

+ do_news: pulls RSS (default Lenta.ru), extracts <title> via regex,
  skips channel title (idx 0), takes 5, sends to Groq for a 2-3
  sentence summary at temp 0.4. Falls back to reading first 3
  headlines verbatim if no token.
+ do_currency_rate: cbr-xml-daily.ru daily_json.js. Recognises
  USD/EUR/CNY/GBP/JPY/KZT in the phrase, computes value/nominal,
  diffs against Previous, speaks Russian with direction.
+ do_crypto_price: CoinGecko simple/price for BTC/ETH/SOL/DOGE/TON
  with 24h change, picked by phrase keywords.
+ do_fun_ask: joke / fact / quote / compliment via Groq at temp 1.0
  with per-kind system prompts, random seed in user message.
+ do_wiki_lookup: ru.wikipedia opensearch → page summary, optional
  Groq retell when extract > 350 chars.
+ do_power: dispatch table for shutdown / restart / sleep /
  hibernate / logoff / cancel — runs shutdown.exe /s|/r|/h|/l|/a
  or rundll32 powrprof,SetSuspendState. 30-second grace on shutdown
  and restart with /c message so "отмени выключение" works.
+ do_help_commands: sorts VA_CMD_LIST.keys(), speaks first 12 + total,
  full list to clipboard.

commands.yaml +26 entries:
news_headlines, currency_rate, crypto_price, joke / fact / quote /
compliment (action:fun_ask, kind: ...), wiki_lookup, shutdown_pc /
restart_pc / sleep_pc / hibernate_pc / logoff_user / cancel_shutdown
(action:power, op: ...), help.

Inspired by what mainstream voice assistants (Siri/Alexa/Алиса/
Cortana) ship by default + power-user utilities (shutdown,
window/process control).
2026-05-15 12:43:26 +03:00
Bossiara13
5790ca18af feat: parity — reminders, today/tomorrow/yesterday, voice type, dice, stopwatch (67 commands)
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)
2026-05-15 12:28:57 +03:00
Bossiara13
69359ed5c0 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).
2026-05-15 12:12:21 +03:00
Bossiara13
3795a0b4ec feat: tier-1 parity — translate, math, theme toggle, projects, sound panel (56 commands total)
Mirrors the rust packs from the same iteration. yaml.safe_load + ast.parse
both pass.

main.py — six new action handlers + a _speak_in_lang helper:
+ do_translate: picks target language from "переведи на X" / "translate to X"
  triggers, defaults to English, sends to Groq with strict "translation only"
  prompt, drops to clipboard, speaks via SAPI in matching ISO voice
+ do_math_eval: strips trigger, Groq at temp=0.0 max_tokens=64,
  short-circuits on "нет" sentinel for non-math
+ do_theme_set: writes AppsUseLightTheme + SystemUsesLightTheme via
  Set-ItemProperty under HKCU\...\Themes\Personalize, action param
  mode: dark|light
+ do_open_project / do_list_projects: reads/creates
  %USERPROFILE%\Documents\jarvis-projects.json (a flat list of
  {name, path}), exact-match → substring-match, opens in VS Code if
  code.cmd on PATH else Explorer. First call writes a sample config
  pointing under USERPROFILE so it works on any host.
+ do_open_sound_panel: rundll32 mmsys.cpl,,1 — Recording tab

commands.yaml — 8 new entries:
+ translate, math_eval
+ theme_dark, theme_light (action params mode:dark/light)
+ open_project, list_projects
+ open_sound_panel

SAPI voice selection iterates GetInstalledVoices() and picks first
matching TwoLetterISOLanguageName — Russian phrases sound Russian,
English/German/etc. sound right too, falls back to default if no
matching voice is installed.

Total commands: 56 (was 49).
2026-05-15 12:06:44 +03:00
Bossiara13
7d83bf8299 feat: tier-1 PC tools parity — window mgmt, clipboard, notes, process killer, web search
Mirrors the new rust packs from the same iteration. Total commands in
commands.yaml is now 49 (was 33). yaml.safe_load + ast.parse both pass.

Window management (8 commands) — uses the existing `keys` action with
pyautogui hotkey strings: winleft+d (show_desktop), winleft+up/down
(maximize/minimize), winleft+left/right (snap left/right), alt+f4
(close_window), winleft+shift+m (restore_all), winleft+tab (task_view).
No new action type needed.

Clipboard read aloud — new clipboard_read action. Shells to
PowerShell Get-Clipboard, caps preview at 400 chars, speaks via the
existing tts.va_speak (which already handles recorder.stop/start).

Notes — new notes_add + notes_open actions. notes_add strips the
trigger phrase, appends "[YYYY-MM-DD HH:MM] body\n" to
%USERPROFILE%\Documents\jarvis-notes.txt, creates the dir if needed.
notes_open shells "cmd /c start" so .txt opens in the user-default
editor.

Process killer — new process_kill action. Strips the trigger ("убей
процесс"/"закрой программу"/etc.), looks up the remainder in the same
alias map as the rust version (хром→chrome, телега→telegram, спотифай
→spotify, едж/эдж/edge→msedge, дискорд→discord, стим→steam, обс→obs64,
проводник/блокнот/калькулятор/диспетчер задач, vs code→code, etc.),
appends .exe if needed, runs taskkill /F /IM, speaks success or
"не нашёл процесс N".

Web search — new websearch action with a `service` param. Bases:
google → https://www.google.com/search?q=
youtube → https://www.youtube.com/results?search_query=
wiki → https://ru.wikipedia.org/wiki/Special:Search?search=
yandex → https://yandex.ru/search/?text=
Strips trigger, urllib.parse.quote-s the rest, opens via webbrowser.open
(respects the user'\''s default browser; config.BROWSER_PATHS only kicks in
for the older url-action commands).

Portability: USERPROFILE used for notes path, no other hardcoded paths.
2026-05-15 11:27:19 +03:00
Bossiara13
80a685230c feat: parity with rust fork — LLM auto-fallback + 6 new action types + 20+ commands
Brings the python fork in line with the new feature/pc-tools work that
landed in rust. Same UX, same trigger phrases, same env vars.

config.py:
  + LLM_AUTO_FALLBACK = True
  + LLM_AUTO_FALLBACK_MIN_CHARS = 4
    Mirrors the rust config knobs. The old "скажи X" trigger still works
    (VA_TBR strips it before intent matching, so what remains hits this
    fallback anyway) — but now ANY unrecognized query goes to Groq, not
    just "скажи" ones.

main.py:
  va_respond: replaced the fuzz-on-"скажи" gate with a unified auto-
  fallback that fires whenever no command matches, GROQ_TOKEN is set,
  and the utterance is at least LLM_AUTO_FALLBACK_MIN_CHARS long.

  + _speak(text): recorder.stop() → tts.va_speak → start, used by the
    new action handlers so the mic doesn't pick up the assistant.
  + _set_clipboard(text): subprocess to PowerShell Set-Clipboard, no
    new python deps.
  + _strip_trigger(voice, triggers): shared helper for codegen/file_search
    where we need the text AFTER the trigger phrase.

  New action handlers (registered in run_action dispatch):
  + groq_codegen — extracts query, calls Groq with strict "code only"
    system prompt at temp 0.2, strips ```lang ... ``` fences, puts the
    result in the clipboard, speaks "Код в буфере, сэр. Строк: N".
  + ocr_screen — PowerShell System.Drawing screenshot to a temp PNG,
    shells to tesseract.exe (-l rus+eng), speaks a 120-char preview,
    clipboard. Looks for tesseract on PATH first then standard install
    dirs; gives a clear "Tesseract не установлен" if missing.
  + sysinfo — CIM-based battery/cpu/ram/disk via PS one-liners, time
    via python datetime (gets us localized weekday for free). Topics:
    time / battery / cpu / ram / disk / all.
  + file_search — strips trigger, recursive Get-ChildItem in Desktop/
    Documents/Downloads (depth 3, top 5), opens explorer /select on
    the first hit, speaks count and basename.
  + audio_devices_panel — opens mmsys.cpl. Proper COM-based device
    switching (like the rust IPolicyConfig version) is deferred — TODO
    in backlog.

commands.yaml:
  Total 33 commands (was 19). New:
  + open_notepad / open_calculator / open_explorer / open_terminal
    / open_settings / open_task_manager / lock_screen / screenshot
    — all via shell action (no AHK exes needed).
  + volume_up / volume_down — five chained keys=volumeup/volumedown
    presses each, like rust's _volume_helper.ps1 with Times=5.
  + media_stop — keys=stop.
  + sysinfo_time / sysinfo_battery / sysinfo_cpu / sysinfo_ram /
    sysinfo_disk / sysinfo_all — sysinfo action.
  + file_search, codegen, read_screen, switch_audio_device.

  Replaced (were AHK exes that Priler ships and we don't have):
  ~ music → keys playpause
  ~ music_off → keys playpause (toggle)
  ~ music_next → keys nexttrack
  ~ music_prev → keys prevtrack

  Removed (Priler hardware/setup specific, can't sensibly port):
  - gaming_mode_on / gaming_mode_off (display config switch)
  - music_save (depends on the specific player AHK was driving)
  - switch_to_headphones / switch_to_dynamics (replaced with the
    generic switch_audio_device that opens mmsys.cpl)

  Kept as-is (worked already, no AHK dependency):
  open_browser / open_youtube / open_google (url action)
  sound_off / sound_on (pycaw via system.volume_mute/unmute)
  thanks / stupid / off

Smoke-tested: yaml.safe_load → 33 commands, ast.parse main.py → OK.

Portability: USERPROFILE for screenshot path, $env:SystemDrive for disk,
PATH lookup for tesseract before falling back to standard install dirs.
No hardcoded user-specific paths.
2026-05-15 01:48:34 +03:00
53 changed files with 10689 additions and 166 deletions

57
.github/workflows/ci.yml vendored Normal file
View file

@ -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

11
.gitignore vendored
View file

@ -8,6 +8,17 @@ __pycache__/
# Custom # Custom
model_small/ model_small/
# Runtime state generated by tests / app — never tracked
profiles/
long_term_memory.json
schedule.json
macros.json
llm_backend.txt
llm_history.json
active_profile.txt
time_tracker.json
expenses.json
# C extensions # C extensions
*.so *.so

View file

@ -37,6 +37,35 @@ Vosk-модель для русского языка лежит в `model_small/
python main.py 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`: - `config.py`:

File diff suppressed because it is too large Load diff

View file

@ -31,6 +31,12 @@ GROQ_TOKEN = os.getenv('GROQ_TOKEN')
GROQ_BASE_URL = "https://api.groq.com/openai/v1" GROQ_BASE_URL = "https://api.groq.com/openai/v1"
GROQ_MODEL = "llama-3.3-70b-versatile" 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 сек. # VAD (webrtcvad) — определяет конец команды по тишине вместо фиксированных 10 сек.
# 0..3, выше — агрессивнее режет шум (но и обрезает речь). # 0..3, выше — агрессивнее режет шум (но и обрезает речь).
VAD_AGGRESSIVENESS = 2 VAD_AGGRESSIVENESS = 2

313
dev_handlers.py Normal file
View file

@ -0,0 +1,313 @@
"""Developer-oriented handlers — port of `codebase_qa/` and `github_pr/` packs.
Codebase Q&A: point Jarvis at a folder, ask questions; he walks source files
(depth-limited, size-capped) and feeds a digest to the LLM.
GitHub PR review: requires the `gh` CLI authenticated. Lists open PRs or
reviews the latest one via LLM.
Both need GROQ_TOKEN (or a configured OpenAI-compat endpoint).
"""
import json
import os
import re
import subprocess
import memory_store
import llm_backend
_speak_fn = print
def set_speak(fn):
global _speak_fn
_speak_fn = fn
def _speak(text):
try:
_speak_fn(text)
except Exception as exc:
print(f"[dev] speak: {exc}")
# ── codebase Q&A ────────────────────────────────────────────────────────────
_EXT_OK = {
'rs', 'lua', 'py', 'ts', 'tsx', 'js', 'jsx', 'svelte',
'go', 'java', 'kt', 'c', 'h', 'cpp', 'hpp', 'cs',
'rb', 'php', 'sh', 'ps1', 'sql', 'toml', 'yaml', 'yml',
'json', 'md',
}
_SKIP_DIR = {
'.git', 'target', 'node_modules', 'dist', 'build',
'__pycache__', '.venv', 'venv', '.idea', '.vscode',
}
_MAX_FILE_BYTES = 4096
_MAX_TOTAL_BYTES = 50_000
_MAX_FILES = 30
_MAX_DEPTH = 3
def _walk_codebase(root: str) -> list[tuple[str, str]]:
"""Return list of (relative_path, content) tuples — capped per the constants above."""
chunks = []
total_bytes = 0
root = os.path.normpath(root)
if not os.path.isdir(root):
return chunks
for dirpath, dirnames, filenames in os.walk(root):
# Depth gate
rel = os.path.relpath(dirpath, root)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > _MAX_DEPTH:
dirnames[:] = []
continue
# Prune skip dirs in-place
dirnames[:] = [d for d in dirnames
if d not in _SKIP_DIR and not d.startswith('.')]
for fname in filenames:
if len(chunks) >= _MAX_FILES or total_bytes >= _MAX_TOTAL_BYTES:
return chunks
_, dot_ext = os.path.splitext(fname)
ext = (dot_ext or '').lstrip('.').lower()
if ext not in _EXT_OK:
continue
fp = os.path.join(dirpath, fname)
try:
with open(fp, 'r', encoding='utf-8', errors='replace') as fh:
content = fh.read(_MAX_FILE_BYTES + 1)
except (OSError, ValueError):
continue
if len(content) > _MAX_FILE_BYTES:
content = content[:_MAX_FILE_BYTES] + "\n... [truncated]"
rel_path = os.path.relpath(fp, root)
chunks.append((rel_path, content))
total_bytes += len(content)
return chunks
def do_codebase_set(action, voice):
"""'Укажи проект C:\\Jarvis\\rust' — stores root via memory."""
body = (voice or '').strip()
low = body.lower()
for trig in ('укажи папку проекта', 'укажи кодовую базу',
'укажи проект', 'выбери проект', 'проект сейчас'):
if low.startswith(trig):
body = body[len(trig):].strip(' ,.:')
break
if not body:
_speak("Укажите путь к папке проекта.")
return
if not os.path.isdir(body):
_speak(f"Папка не найдена: {body}.")
return
memory_store.remember("codebase.root", body)
_speak("Проект установлен.")
def do_codebase_where(action, voice):
root = memory_store.recall("codebase.root")
if not root:
_speak("Проект не выбран. Скажите: укажи проект и путь.")
return
_speak(f"Сейчас работаю с проектом {root}.")
def do_codebase_ask(action, voice):
"""'Что делает функция X' / 'найди в коде Y'."""
root = memory_store.recall("codebase.root")
if not root:
_speak("Сначала укажите проект.")
return
body = (voice or '').strip()
low = body.lower()
for trig in ('что делает функция', 'вопрос по коду',
'спроси код', 'найди в проекте',
'найди в коде', 'что в коде', 'объясни код'):
idx = low.find(trig)
if idx >= 0:
body = body[idx + len(trig):].strip(' ,.:')
break
if not body:
_speak("Сформулируйте вопрос.")
return
chunks = _walk_codebase(root)
if not chunks:
_speak("Не нашёл исходников в проекте.")
return
digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks)
client = llm_backend.current_client()
if client is None:
_speak("LLM не настроен.")
return
model = llm_backend.current_model()
user_prompt = (
f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя "
f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n"
f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}"
)
try:
resp = client.chat.completions.create(
model=model,
messages=[
{'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'},
{'role': 'user', 'content': user_prompt},
],
max_tokens=400, temperature=0.2, timeout=45,
)
answer = resp.choices[0].message.content.strip()
except Exception as exc:
print(f"[dev] codebase LLM: {exc}")
_speak("Не получилось получить ответ.")
return
if not answer:
_speak("Пустой ответ.")
return
_speak(answer)
# ── github PR ───────────────────────────────────────────────────────────────
def _gh(*args, timeout=15):
"""Run gh CLI, return (success, stdout, stderr)."""
try:
res = subprocess.run(
['gh', *args],
capture_output=True, text=True, encoding='utf-8',
timeout=timeout,
)
return res.returncode == 0, res.stdout, res.stderr
except FileNotFoundError:
return False, '', 'gh CLI not installed'
except Exception as exc:
return False, '', str(exc)
def do_github_set_repo(action, voice):
body = (voice or '').strip()
low = body.lower()
for trig in ('установи репо', 'выбери репо', 'текущий репо', 'репозиторий'):
if low.startswith(trig):
body = body[len(trig):].strip(' ,.:')
break
if not body or '/' not in body:
_speak("Скажите owner slash repo, например bossiara13 slash J A R V I S.")
return
memory_store.remember("github.repo", body)
_speak(f"Репозиторий {body} установлен.")
def do_github_list_prs(action, voice):
repo = memory_store.recall("github.repo")
if not repo:
_speak("Сначала укажите репозиторий: текущий репо owner/repo.")
return
ok, out, err = _gh('pr', 'list', '--repo', repo, '--state', 'open',
'--json', 'number,title,author', '--limit', '10')
if not ok:
print(f"[dev] gh list: {err[:200]}")
_speak("gh CLI не отвечает. Установите gh и выполните gh auth login.")
return
try:
prs = json.loads(out or '[]')
except json.JSONDecodeError:
prs = []
if not prs:
_speak("Открытых пиаров нет.")
return
line = f"{len(prs)} открытых пиаров. "
titles = [p.get('title', '') for p in prs[:3] if p.get('title')]
if titles:
line += "Первые: " + ". ".join(titles) + "."
_speak(line)
def do_github_summarize_pr(action, voice):
repo = memory_store.recall("github.repo")
if not repo:
_speak("Сначала укажите репозиторий.")
return
# Most recent open PR number
ok, out, _ = _gh('pr', 'list', '--repo', repo, '--state', 'open',
'--json', 'number', '--limit', '1')
if not ok:
_speak("gh CLI не отвечает.")
return
try:
items = json.loads(out or '[]')
number = items[0]['number'] if items else None
except (json.JSONDecodeError, KeyError, IndexError):
number = None
if not number:
_speak("Открытых пиаров нет.")
return
ok, out, _ = _gh('pr', 'view', str(number), '--repo', repo,
'--json', 'title,body,additions,deletions,changedFiles,author')
if not ok:
_speak("Не получилось получить PR.")
return
try:
pr = json.loads(out)
except json.JSONDecodeError:
_speak("gh вернул битый JSON.")
return
title = pr.get('title', '(no title)')
body = (pr.get('body') or '')[:3000]
additions = pr.get('additions', '?')
deletions = pr.get('deletions', '?')
files = pr.get('changedFiles', '?')
author = (pr.get('author') or {}).get('login', '(unknown)')
client = llm_backend.current_client()
if client is None:
_speak(f"PR номер {number}: {title}. Без LLM — настройте Groq или Ollama.")
return
model = llm_backend.current_model()
user_prompt = (
f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n"
f"Changed files: {files} (+{additions}, -{deletions})\n"
f"Description:\n{body}\n\n"
"Кратко (3-5 предложений) на русском: что меняет этот PR, "
"какие риски, стоит ли мёржить."
)
try:
resp = client.chat.completions.create(
model=model,
messages=[
{'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."},
{'role': 'user', 'content': user_prompt},
],
max_tokens=400, temperature=0.2, timeout=45,
)
review = resp.choices[0].message.content.strip()
except Exception as exc:
print(f"[dev] PR LLM: {exc}")
_speak("Не получилось проанализировать.")
return
if not review:
_speak("Пустая сводка.")
return
_speak(f"Пиар номер {number}: {review}")

177
expenses_handlers.py Normal file
View file

@ -0,0 +1,177 @@
"""Personal finance tracker — Python parity for the Rust `expenses/` pack.
Stores entries in `<here>/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"\а\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}.")

1191
extensions.py Normal file

File diff suppressed because it is too large Load diff

5
gui.bat Normal file
View file

@ -0,0 +1,5 @@
@echo off
REM Launch the Flet GUI. Requires `pip install flet>=0.85`.
pushd "%~dp0"
python -m gui
popd

19
gui/__init__.py Normal file
View file

@ -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).
"""

5
gui/__main__.py Normal file
View file

@ -0,0 +1,5 @@
"""`python -m gui` — launch the Flet GUI."""
from gui.app import run
if __name__ == "__main__":
run()

81
gui/app.py Normal file
View file

@ -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/<name>.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)

1
gui/pages/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Page modules: each exposes `build(page: flet.Page) -> flet.Control`."""

80
gui/pages/commands.py Normal file
View file

@ -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,
)

119
gui/pages/home.py Normal file
View file

@ -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

90
gui/pages/macros.py Normal file
View file

@ -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,
)

74
gui/pages/memory.py Normal file
View file

@ -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),
)

97
gui/pages/plugins.py Normal file
View file

@ -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,
)

80
gui/pages/scheduler.py Normal file
View file

@ -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,
)

77
gui/pages/settings.py Normal file
View file

@ -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,
)

159
gui/services.py Normal file
View file

@ -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

152
llm_backend.py Normal file
View file

@ -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 `<here>/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

179
macros_store.py Normal file
View file

@ -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 `<here>/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)

1629
main.py

File diff suppressed because it is too large Load diff

136
memory_store.py Normal file
View file

@ -0,0 +1,136 @@
"""Long-term memory store — port of `crates/jarvis-core/src/long_term_memory.rs`.
Stores user facts in JSON at `<script_dir>/long_term_memory.json`.
Atomic write-through (write-then-rename).
Public API:
remember(key, value) -> None
recall(key) -> str | None
forget(key) -> bool
search(query, limit=5) -> list[dict]
all_facts() -> list[dict]
build_llm_context(prompt, limit=5) -> str # used by chat handler
"""
import json
import os
import time
# JSON file lives in the same folder as main.py so the user can move the
# install and the data follows.
_HERE = os.path.dirname(os.path.abspath(__file__))
_PATH = os.path.join(_HERE, 'long_term_memory.json')
_store: dict[str, dict] = {}
_loaded = False
def _load():
global _store, _loaded
if _loaded:
return
if os.path.isfile(_PATH):
try:
with open(_PATH, encoding='utf-8') as f:
data = json.load(f)
_store = data.get('entries', {}) if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError) as exc:
print(f"[memory] corrupt store ({exc}) — starting empty")
_store = {}
_loaded = True
def _save():
tmp = _PATH + '.tmp'
try:
with open(tmp, 'w', encoding='utf-8') as f:
json.dump({'entries': _store}, f, ensure_ascii=False, indent=2)
os.replace(tmp, _PATH)
except OSError as exc:
print(f"[memory] save failed: {exc}")
def _normalize_key(k: str) -> str:
return (k or '').strip().lower()
def remember(key: str, value: str) -> None:
_load()
nk = _normalize_key(key)
if not nk:
return
now = int(time.time())
if nk in _store:
_store[nk]['value'] = value
_store[nk]['last_used_at'] = now
else:
_store[nk] = {
'key': nk,
'value': value,
'created_at': now,
'last_used_at': now,
'use_count': 0,
}
_save()
def recall(key: str) -> str | None:
_load()
nk = _normalize_key(key)
entry = _store.get(nk)
if not entry:
return None
entry['last_used_at'] = int(time.time())
entry['use_count'] = entry.get('use_count', 0) + 1
_save()
return entry['value']
def forget(key: str) -> bool:
_load()
nk = _normalize_key(key)
if nk in _store:
del _store[nk]
_save()
return True
return False
def clear_all() -> int:
"""Wipe every fact. Returns count of removed entries. Atomic via _save."""
_load()
n = len(_store)
if n == 0:
return 0
_store.clear()
_save()
return n
def search(query: str, limit: int = 5) -> list[dict]:
_load()
nq = _normalize_key(query)
if not nq:
return []
hits = [
e for e in _store.values()
if nq in e['key'] or nq in e['value'].lower()
]
hits.sort(key=lambda e: e.get('last_used_at', 0), reverse=True)
return hits[: max(1, limit)]
def all_facts() -> list[dict]:
_load()
return list(_store.values())
def build_llm_context(prompt: str, limit: int = 5) -> str:
"""Format relevant memory facts as a system-prompt addendum."""
hits = search(prompt, limit)
if not hits:
return ''
lines = ["Известные факты о пользователе (используй если уместно):"]
for h in hits:
lines.append(f"- {h['key']} = {h['value']}")
return '\n'.join(lines) + '\n'

308
outlook_handlers.py Normal file
View file

@ -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("&nbsp;", " ").replace("&amp;", "&")
.replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", '"').replace("&#39;", "'"))
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)

192
personality_handlers.py Normal file
View file

@ -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))

154
plugins_store.py Normal file
View file

@ -0,0 +1,154 @@
r"""User plugin loader — mirror of the Rust plugin system.
Plugins live in `<APP_CONFIG_DIR>/plugins/<name>/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):
<APP_CONFIG_DIR>/plugins/
<pack_name>/
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 `<APP_CONFIG_DIR>/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)

168
profiles_store.py Normal file
View file

@ -0,0 +1,168 @@
"""Profile switching — port of `crates/jarvis-core/src/profiles.rs`.
Profiles live in `<script_dir>/profiles/<name>.json`. Active profile name
persists in `<script_dir>/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)

View file

@ -27,6 +27,10 @@ sounddevice
PyYAML PyYAML
python-dotenv 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) # Semantic intent classifier (MiniLM-L6-v2 ONNX)
onnxruntime>=1.17 onnxruntime>=1.17
tokenizers>=0.15 tokenizers>=0.15
@ -38,3 +42,8 @@ noisereduce>=3.0
ruamel.yaml ruamel.yaml
pywebview pywebview
pyautogui 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

13
run.bat Normal file
View file

@ -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

307
scheduler_store.py Normal file
View file

@ -0,0 +1,307 @@
"""Proactive scheduler — port of `crates/jarvis-core/src/scheduler.rs`.
JSON store at <here>/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 <unit>
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 <unit>
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)")

0
tests/__init__.py Normal file
View file

45
tests/conftest.py Normal file
View file

@ -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

View file

@ -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()

49
tests/test_gui_smoke.py Normal file
View file

@ -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"

68
tests/test_llm_backend.py Normal file
View file

@ -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'

View file

@ -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)

View file

@ -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) == ""

View file

@ -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("<b>Hi</b>&nbsp;there") == "Hi there"
assert mod._strip_html("") == ""

View file

@ -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

View file

@ -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

View file

@ -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')

View file

@ -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

179
tests/test_time_tracker.py Normal file
View file

@ -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()

View file

@ -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]

View file

@ -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

263
time_tracker_handlers.py Normal file
View file

@ -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 `<script_dir>/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))

46
tts.py
View file

@ -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 time
import numpy as np import numpy as np
@ -14,13 +25,31 @@ speaker = 'aidar' # aidar, baya, kseniya, xenia, random
put_accent = True put_accent = True
put_yo = True put_yo = True
device = torch.device('cpu') # cpu или gpu 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', model='silero_tts',
language=language, language=language,
speaker=model_id) 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): def _post_process(audio):
@ -45,11 +74,12 @@ def _post_process(audio):
def synthesize(what: str): def synthesize(what: str):
audio = model.apply_tts(text=what + "..", m = _ensure_model()
speaker=speaker, audio = m.apply_tts(text=what + "..",
sample_rate=sample_rate, speaker=speaker,
put_accent=put_accent, sample_rate=sample_rate,
put_yo=put_yo) put_accent=put_accent,
put_yo=put_yo)
return _post_process(audio) return _post_process(audio)

152
vision_handler.py Normal file
View file

@ -0,0 +1,152 @@
"""Vision handler — port of `crates/jarvis-core/src/lua/api/vision.rs`.
Captures the primary screen via PowerShell System.Drawing, base64-encodes
the PNG, sends to Groq's vision-capable model. Requires GROQ_TOKEN.
Action types in commands.yaml:
vision_describe "что на экране" / "опиши экран"
vision_read_error "прочитай ошибку"
"""
import base64
import os
import subprocess
import tempfile
_speak_fn = print
def set_speak(fn):
global _speak_fn
_speak_fn = fn
def _speak(text):
try:
_speak_fn(text)
except Exception as exc:
print(f"[vision] speak: {exc}")
_SCREENSHOT_PS_TEMPLATE = """
Add-Type -AssemblyName System.Windows.Forms;
Add-Type -AssemblyName System.Drawing;
$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds;
$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height;
$g = [System.Drawing.Graphics]::FromImage($bmp);
$g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size);
$bmp.Save('{path}', [System.Drawing.Imaging.ImageFormat]::Png);
$g.Dispose(); $bmp.Dispose();
"""
def _take_screenshot() -> str | None:
"""Save full screen to a temp PNG, return path or None on failure."""
fd, path = tempfile.mkstemp(prefix='jarvis-screen-', suffix='.png')
os.close(fd)
ps = _SCREENSHOT_PS_TEMPLATE.format(path=path.replace("'", "''"))
try:
subprocess.run(
['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],
capture_output=True, timeout=10, check=True,
)
except Exception as exc:
print(f"[vision] screenshot: {exc}")
try:
os.unlink(path)
except OSError:
pass
return None
if not os.path.isfile(path) or os.path.getsize(path) < 1000:
try:
os.unlink(path)
except OSError:
pass
return None
return path
def _vision_call(prompt: str, image_b64: str) -> str | None:
# Vision is Groq-specific (Ollama doesn't expose vision via OpenAI-compat
# endpoint in our stack). Use config.GROQ_TOKEN directly regardless of the
# active text-LLM backend.
try:
import config as cfg
except ImportError:
return None
if not getattr(cfg, 'GROQ_TOKEN', None):
return None
try:
from openai import OpenAI
except ImportError:
return None
client = OpenAI(
api_key=cfg.GROQ_TOKEN,
base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'),
)
model = os.environ.get('GROQ_VISION_MODEL', 'llama-3.2-11b-vision-preview')
try:
resp = client.chat.completions.create(
model=model,
messages=[{
'role': 'user',
'content': [
{'type': 'text', 'text': prompt},
{'type': 'image_url', 'image_url': {
'url': f'data:image/png;base64,{image_b64}',
}},
],
}],
max_tokens=400, temperature=0.2, timeout=30,
)
return resp.choices[0].message.content.strip()
except Exception as exc:
print(f"[vision] LLM call: {exc}")
return None
def _describe(prompt: str) -> str | None:
"""Take a screenshot and ask vision-LLM. Returns description or None."""
path = _take_screenshot()
if not path:
return None
try:
with open(path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('ascii')
finally:
try:
os.unlink(path)
except OSError:
pass
return _vision_call(prompt, b64)
def do_vision_describe(action, voice):
_speak("Сейчас посмотрю.")
prompt = (
"Опиши коротко (1-3 предложения) что сейчас на экране. По-русски."
)
result = _describe(prompt)
if not result:
_speak("Не удалось разобрать экран. Проверь GROQ_TOKEN.")
return
_speak(result)
def do_vision_read_error(action, voice):
_speak("Смотрю на ошибку.")
prompt = (
"Найди на экране текст ошибки (сообщение об ошибке, stack trace, диалог "
"об исключении). Прочитай ключевое сообщение и одним коротким "
"предложением подскажи возможную причину. Если ошибки нет — скажи "
"'ошибок не вижу'. Отвечай по-русски. 1-3 предложения максимум."
)
result = _describe(prompt)
if not result:
_speak("Не получилось прочитать.")
return
_speak(result)

613
wave1_handlers.py Normal file
View file

@ -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[^>]*>([^<]+)</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) + ".")

289
wave59_handlers.py Normal file
View file

@ -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_<alias>"] 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)

278
wave68_handlers.py Normal file
View file

@ -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))