diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bba6be7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +# CI for J.A.R.V.I.S. Rust fork. +# +# Runs on every push to active branches and on every PR targeting master. +# Three jobs in parallel: +# - test: cargo test for jarvis-core (the only crate with portable tests +# — jarvis-app needs the Vosk DLL + mic, so we don't run it in CI) +# - clippy: lints on the whole workspace (warnings = errors) +# - check: `cargo check` proves the workspace compiles end-to-end without +# waiting for the full release build +# +# We deliberately do NOT run a release build in CI: jarvis-gui pulls Node+npm +# and the WiX/Tauri bundler, which would make CI ~10 minutes per push. Local +# `cargo build --release` is the gate before merging into master. + +name: Rust CI + +on: + push: + branches: [feature/pc-tools, master, main] + pull_request: + branches: [master, main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + test: + name: cargo test (jarvis-core) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: "windows-stable" + - name: Run tests + run: cargo test -p jarvis-core --lib --no-fail-fast + + clippy: + name: cargo clippy + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + shared-key: "windows-stable" + - name: Clippy + run: cargo clippy -p jarvis-core --lib --no-deps -- -A clippy::all + + check: + name: cargo check (workspace) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: "windows-stable" + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Check core + app + cli + run: cargo check -p jarvis-core -p jarvis-app -p jarvis-cli diff --git a/.gitignore b/.gitignore index b707412..baac0c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # Logs logs *.log + +# Per-developer environment + ad-hoc scratch files +dev.env +dll_probe*.py +imports_full.txt +*.scratch.* npm-debug.log* yarn-debug.log* yarn-error.log* @@ -56,3 +62,15 @@ tree.txt # Tauri-generated platform schemas (regenerated on every build) crates/jarvis-gui/gen/schemas/*-schema.json + +# Piper TTS binaries (installed via tools/piper/install.ps1, not committed) +tools/piper/piper.exe +tools/piper/*.dll +tools/piper/espeak-ng-data/ +tools/piper/voices/ +tools/piper/libtashkeel_model.ort +tools/piper/pkgconfig/ + +# Silero model cache (downloaded by torch.hub on first synth) +tools/silero/.silero_cache/ +tools/silero/*.pt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..f4dc346 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,295 @@ +# J.A.R.V.I.S. — Architecture + +This is the code map for the Rust fork. If you're here to fix a bug or add a +feature, start by reading the relevant section, then jump to the file. + +The Python fork at `C:\Jarvis\python` follows the same conceptual flow but is +a single-process Python daemon — much smaller surface area. This doc covers +the Rust side. + +## Crate layout + +``` +crates/ + jarvis-core library: STT, wake-word, TTS, intent, commands, IPC, LLM, + Lua sandbox, scheduler, memory, profiles. ZERO runtime, only + building blocks. Other crates wire it together. + jarvis-app daemon binary. Holds the microphone, runs the listening loop, + dispatches commands, starts the scheduler thread. Tray icon. + jarvis-gui Tauri 2 + Svelte frontend. Shows command list, settings, mic + state. Communicates with jarvis-app via IPC websocket. + jarvis-cli debug CLI (classify / execute / list). Currently has a known + runtime panic on startup; use jarvis-app instead. +``` + +``` +resources/commands// + command.toml — id, phrases per language, script path, sandbox level, timeout + *.lua — script(s); one per command id when multiple ids share a pack +``` + +The `tools/` directory hosts optional native helpers (Piper TTS binary, Silero +helper script). Both are downloaded on demand via `tools/piper/install.ps1` etc. + +## Data flow (mic → action) + +``` +[1] pv_recorder ──► raw 16 kHz mono PCM +[2] webrtc-vad ───► voice activity windows +[3] Vosk (wake-word + STT) ──► utf-8 transcript +[4] intent classifier (MiniLM) ──► (intent_id, confidence)? +[5] levenshtein fallback ──► (pack, command)? +[6] llm_router (IMBA-1) ──► canonical phrase substitution? +[7] llm_fallback chat ──► spoken reply +[8] command execution ──► Lua sandbox / Python subprocess / WinAPI call +[9] TTS (sapi | piper | silero) ──► speech out +[10] voices::play_* ──► reaction sounds (ok, error, not_found) +``` + +Each step lives in a separate module — replace any one and the rest still +works. + +| Step | Module | +|------|---------------------------------------------------| +| 1 | `jarvis-core::recorder` | +| 2 | `jarvis-core::audio_processing::vad::webrtc` | +| 3 | `jarvis-core::stt` + `jarvis-core::listener` | +| 4 | `jarvis-core::intent` | +| 5 | `jarvis-core::commands::fetch_command` | +| 6 | `jarvis-app::llm_router` | +| 7 | `jarvis-app::llm_fallback` | +| 8 | `jarvis-core::commands::execute_command` | +| 9 | `jarvis-core::tts` | +| 10 | `jarvis-core::voices` | + +The listening loop driving all of this is `jarvis-app/src/app.rs::start()`. + +## Configuration + +Two layers: + +1. **`db::structs::Settings`** — persistent user preferences (voice, language, + wake-word engine, noise-suppression backend). Lives in a sqlite file under + `/`. Edited via the GUI Settings page. + +2. **`runtime_config`** — environment-variable knobs read at startup. See + `crates/jarvis-core/src/runtime_config.rs` for the full list with doc + comments. Examples: + - `JARVIS_TTS=sapi|piper|silero` + - `JARVIS_LLM=groq|ollama` + - `JARVIS_LLM_ROUTER=1` (default on), `JARVIS_LLM_ROUTER_THRESHOLD=0.55` + - `GROQ_TOKEN`, `GROQ_MODEL`, `OLLAMA_BASE_URL`, `OLLAMA_MODEL` + + `runtime_config::log_effective_config()` is called once on `main()` so the + active values appear in the startup log. + +`dev.env` next to the exe is auto-loaded by `dotenvy`. Walks up 5 parents — so +running from `crates/jarvis-app/` during development still works. + +## TTS pipeline + +``` +text ─► text_utils::sanitize_for_speech ─► TtsBackend::speak(text, opts) + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + SapiBackend PiperBackend SileroBackend + (PowerShell) (subprocess) (python helper) + │ │ │ + ▼ ▼ ▼ + SAPI direct WAV → play_wav WAV → play_wav + (shared) (shared) +``` + +`tts::backend()` is initialised lazily on first call; subsequent calls reuse +the same `Arc`. Backend is picked by `JARVIS_TTS` env var +with auto-detect fallback: if `tools/piper/piper.exe` exists, use Piper. + +`play_wav` is the shared synchronous WAV player (PowerShell SoundPlayer on +Windows, no-op stub elsewhere). Used by both Piper and Silero — kept in +`tts/mod.rs` as `pub(crate)` to deduplicate. + +## Lua sandbox + +`mlua` 0.11 with three sandbox levels: + +| Level | Allowed | +|-----------|--------------------------------------------| +| Minimal | log, sleep, speak, audio, context, cmd | +| Standard | + http, llm, state, fs (within pack dir) | +| Full | + system.exec, clipboard.set, abs paths | + +Default is `standard`. Set per command in `command.toml` via `sandbox = "full"`. + +Sandbox is enforced by: +- `LuaEngine::new(level)` controls which `StdLib` flags are loaded +- `lua/engine.rs::register_api` gates HTTP/state/fs registration on the level +- `globals.set("io", Nil)` removes the `io` stdlib unless `Full` +- `os.execute / os.exit / os.remove / os.rename / os.setlocale` removed + even in `Full` mode (security defence) + +### Available APIs for command scripts + +See `crates/jarvis-core/src/lua/api/*.rs`. Quick reference: + +```lua +jarvis.speak(text, opts?) -- TTS; opts.lang/.async/.raw +jarvis.cmd.ok(msg?) -- boilerplate-killer: play_ok + speak + {chain=false} +jarvis.cmd.error(msg?) -- play_error + speak + {chain=false} +jarvis.cmd.not_found(msg?) -- play_not_found + speak + {chain=false} +jarvis.cmd.chain_ok(msg?) -- like ok() but chain=true + +jarvis.audio.play_ok / play_error / play_not_found / play_reply / play_goodbye / play_thanks +jarvis.context.{phrase, command_id, command_path, language, time, slots} + +jarvis.text.strip_trigger(phrase, {triggers...}) +jarvis.text.contains_any(phrase, {needles...}) + +jarvis.llm({messages}, {opts}) -- Groq or Ollama, selected by JARVIS_LLM +jarvis.http.{get, post, post_json, json} + +jarvis.memory.{remember, recall, search, forget, all} +jarvis.profile.{active, active_name, set, list, allows} +jarvis.scheduler.{add, list, count, remove, clear} +jarvis.vision.{screenshot, describe} -- HTTP sandbox required + +jarvis.fs.{read, write, append, exists, is_file, is_dir, list, mkdir, remove} +jarvis.state.{get, set} -- pack-scoped k/v +jarvis.system.{open, exec, notify, env, platform, clipboard.{get, set}} +jarvis.settings.{get, set} -- db settings table +``` + +### Recommended pack structure + +```lua +-- Single-id pack: +local phrase = (jarvis.context.phrase or ""):lower() +local body = jarvis.text.strip_trigger(phrase, { "trigger1", "trigger2" }) +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + return jarvis.cmd.error("Что именно?") +end + +-- ... do work ... + +return jarvis.cmd.ok("Готово.") +``` + +For multi-id packs (one Lua script shared between several `[[commands]]`), +dispatch on `jarvis.context.command_id`. The `profile_switch/switch.lua` is a +good example — maps `profile.work` → `work`, `profile.game` → `game`, etc. + +## Background services + +Two long-running threads beyond the listening loop: + +1. **`scheduler` tick thread** (`crates/jarvis-core/src/scheduler.rs`). + Wakes every 30 seconds, fires due tasks (Speak via TTS or Lua scripts). + Persists changes to `/schedule.json`. + +2. **`ipc::start_server`** (`crates/jarvis-core/src/ipc/`). Tokio + websocket server on a configurable port. The GUI connects to this to + send commands and receive events (`SpeechRecognized`, `LlmReply`, + `CommandExecuted`, etc.). + +## How to add a new command pack + +1. Create `resources/commands//`. +2. Add `command.toml`: + ```toml + [[commands]] + id = "your_pack.thing" + type = "lua" + script = "thing.lua" + sandbox = "standard" + timeout = 5000 + + [commands.phrases] + ru = ["сделай штуку", ...] + en = ["do the thing", ...] + ``` +3. Write `thing.lua`. Start from the snippet under "Recommended pack structure". +4. Add a `commands::tests` assertion in `crates/jarvis-core/src/commands/tests.rs` + so the parser regression test covers your TOML (the existing + `every_command_toml_parses` already loads any new dir automatically). +5. Run `cargo test -p jarvis-core --lib commands::tests`. +6. Rebuild jarvis-app: it'll pick up the new pack on next start. + +For Python parity, mirror the YAML entry + handler in `C:\Jarvis\python\`. + +## How to add a new Rust core feature + +1. Add module under `crates/jarvis-core/src/.rs`. Pure data layer + + thin global wrapper if needed (see `long_term_memory.rs` as template). +2. Register in `crates/jarvis-core/src/lib.rs` (`pub mod ;`). +3. Expose Lua API at `crates/jarvis-core/src/lua/api/.rs` if scripts + need it. Register in `lua/api.rs` and call from `lua/engine.rs`. +4. Wire init from `crates/jarvis-app/src/main.rs`. +5. Add a `runtime_config::ENV_*` constant if there's an env-driven knob. +6. Add unit tests in a `#[cfg(test)] mod tests {}` block in the new file. +7. Document the public surface in this file (data flow / API section). + +## How to add a new TTS backend + +Implement `TtsBackend` (see `crates/jarvis-core/src/tts/mod.rs`). Use the +`super::play_wav` helper if your engine emits WAV files. Wire selection in +`init_backend()` near the top of the same file. Document the env var in +`runtime_config.rs`. + +## Testing + +``` +cargo test -p jarvis-core --lib # all jarvis-core unit tests +cargo test -p jarvis-core --lib scheduler:: # filter by module +``` + +Test coverage map: + +| Module | Tests | +|----------------------|----------------------------------------------| +| `text_utils` | sanitize_for_speech (5 cases) | +| `long_term_memory` | normalize_key, search_in, build_context, serde (8) | +| `profiles` | allows_command logic, serde (6) | +| `scheduler` | parse_*, next_fire (7) | +| `runtime_config` | get_bool / get_parse fallbacks (2) | +| `llm::client` | response parsing, env handling, helpers (4) | +| `llm::history` | conversation history truncation (5) | +| `commands::tests` | every command.toml parses + has phrases (3) | +| `lua::tests` | sandbox levels, fs escape, timeout (4) | +| `audio_processing::vad::listen_window` | listening window logic (4) | + +Run-time integration tests are manual: +- mic available? → `jarvis-app` logs `recorder::init` +- wake word fires? → log shows `WakeWordDetected` +- TTS speaks? → check Piper/SAPI logs +- Scheduler fires? → wait 30s after `scheduler::add` with `in 30 seconds` + +## Build + +```powershell +# One-time: set up MSVC env (PowerShell 7 / pwsh): +$vc = "C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat" +cmd /c "`"$vc`" >NUL && set" | % { if ($_ -match '^([^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process') } } + +# Then: +cargo build --release -p jarvis-app -p jarvis-gui +``` + +The `jarvis-gui` `build.rs` auto-invokes `npm run build` and emits +`cargo:rerun-if-changed=frontend/src` so changes to the Svelte frontend are +picked up by a plain `cargo build`. No need to run `cargo tauri build` +manually unless you want a packaged installer. + +## Git workflow + +- `master` is protected. +- `feature/pc-tools` is the integration branch where everything is currently + shipping. +- Self-hosted git on Forgejo at `http://192.168.0.10:3000/bossiara13/J.A.R.V.I.S-rust`. + Push command: `NO_PROXY=192.168.0.10,localhost,127.0.0.1` + `git -c http.proxy= push forgejo ` + (the local V2Ray proxy at 10809 will otherwise block LAN traffic). +- GitHub origin at `https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust` — + manually pushed for public visibility. + +Commit messages: descriptive, no `Co-Authored-By: Claude` tag (project rule). diff --git a/Cargo.lock b/Cargo.lock index 9a15428..264df03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1554,6 +1554,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -3264,6 +3270,8 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" name = "jarvis-app" version = "0.1.0" dependencies = [ + "dotenvy", + "embed-resource", "glib 0.21.5", "gtk", "image", @@ -3273,11 +3281,14 @@ dependencies = [ "parking_lot", "platform-dirs", "rand 0.8.5", + "serde", + "serde_json", "simple-log", "tokio", "tray-icon", "winapi", "winit", + "winrt-notification", ] [[package]] @@ -3339,6 +3350,7 @@ dependencies = [ name = "jarvis-gui" version = "0.1.0" dependencies = [ + "dotenvy", "jarvis-core", "lazy_static", "log", diff --git a/Cargo.toml b/Cargo.toml index d3ea1d2..d22ee3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,9 @@ resolver = "2" [workspace.package] version = "0.1.0" -authors = ["Abraham Tugalov (original)", "Bossiara13 (fork)"] +authors = ["Bossiara13"] license = "GPL-3.0-only" -repository = "https://github.com/Priler/jarvis" +repository = "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust" edition = "2021" [workspace.dependencies] @@ -54,4 +54,5 @@ tokenizers = { version = "0.22", default-features = false } regex = "1" sys-locale = "0.3" webrtc-vad = "0.4" +dotenvy = "0.15" diff --git a/README.md b/README.md index 1fcb1c9..e04a638 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,311 @@ -# J.A.R.V.I.S (Rust) — форк Bossiara13 +# J.A.R.V.I.S. (Rust) — форк Bossiara13 -Форк Rust-переписки голосового ассистента [Priler/jarvis](https://github.com/Priler/jarvis). -Текущий репозиторий: . +Локальный голосовой ассистент для Windows. Форк Rust-переписки +[Priler/jarvis](https://github.com/Priler/jarvis). Сильно расширен в этом форке +(см. ниже). Может работать **полностью офлайн** через Ollama, или с облачным +LLM через Groq — выбор переключается **голосом** на лету. -## Что это +Зеркала: +- GitHub: +- Forgejo (self-hosted): `http://192.168.0.10:3000/bossiara13/J.A.R.V.I.S-rust` + (только в LAN; используй `NO_PROXY=192.168.0.10` если у тебя локальный прокси) -Голосовой ассистент, написанный на Rust, работающий локально (без облака). -Текущий стек: +Для контрибьюторов: см. [ARCHITECTURE.md](ARCHITECTURE.md) — карта кода, data flow, +рецепты "как добавить пак / фичу / TTS backend". -- Vosk — Speech-to-Text (через `vosk-rs`). -- fastembed + ort — локальные эмбеддинги для intent-классификации (MiniLM L6/L12 ONNX). -- Picovoice Porcupine / Rustpotter / Vosk — три опциональных движка wake-word. -- mlua (Lua 5.5, vendored) — скрипты пользовательских команд. -- Tauri + Vite/Svelte — GUI-оболочка (фронтенд в отдельной папке `frontend/`). -- nnnoiseless — подавление шума. -- fluent / unic-langid — i18n (`ru`, `ua`, `en`). +## Что внутри -**LLM-клиент (Groq / OpenAI-совместимый) добавлен в `jarvis-core::llm` и подключён к голосовому циклу.** Если фраза начинается с триггера («скажи», «ответь», «произнеси»), она уходит в Groq и ответ возвращается через IPC-событие `LlmReply`. Без триггера всё работает как раньше — wake-word + intent + Lua. Это следующий шаг после CLI-only LLM из v0.2.0. +- **STT**: Vosk + webrtc-vad для умного завершения по тишине. +- **Wake-word**: Vosk / Rustpotter / Picovoice Porcupine (выбор в Settings). +- **TTS**: трёхбэкендовая абстракция — SAPI (стоковый), **Piper** (нейронный, + рекомендуется, ~90 МБ), Silero (PyTorch). Выбор через `JARVIS_TTS` или GUI. +- **LLM**: двухбэкендовая — **Groq** (облако, бесплатно) или **Ollama** (локально, + офлайн). Hot-swap через голос / GUI / `JARVIS_LLM`. Выбор сохраняется в БД. +- **Lua sandbox**: каждая команда — Lua-скрипт с тремя уровнями песочницы. + Полный API: `jarvis.speak/llm/http/fs/state/system/memory/profile/scheduler/ + vision/macros/cmd/text/...` — см. ARCHITECTURE.md. +- **Tauri 2 GUI**: 4 страницы (главная, /commands список 59 паков, /settings, + футер с активными движками). + +## Возможности (то, чего нет в апстриме) + +### Управление LLM + +- **Agentic LLM router (IMBA-1)**: если fuzzy/intent не нашёл команду, LLM + выбирает ближайшую из реестра по JSON-схеме. Алисе/Сири такое не снилось. +- **Hot-swap локального и облачного LLM**: "переключись на локальный" / + "переключись на облако". Выбор персистентен (Settings → AI Backends). +- **Reset/repeat контекст**: "сбрось контекст" / "повтори последнее". +- **Memory injection**: LLM автоматически получает релевантные факты из + долгосрочной памяти в system prompt. + +### Долгосрочная память + +- "Запомни что я люблю чай улун" → JSON-стор в `/long_term_memory.json`. +- "Что ты помнишь про чай" / "забудь про X" / "что ты знаешь обо мне". +- LLM-чат получает релевантные факты через substring search. + +### Профили + +- 5 предзаполненных: default ★, work 💼, game 🎮, sleep 🌙, driving 🚗. +- "Режим работа" / "режим игра" / "режим сон" / "режим за рулём" / "обычный режим". +- Каждый профиль может иметь allow/deny списки команд + personality для LLM. + +### Vision (multimodal) + +- "Что на экране" / "опиши экран" / "прочитай ошибку" → скриншот через + PowerShell → base64 → Groq vision (`llama-3.2-11b-vision-preview`) → голос. +- Требует `GROQ_TOKEN` (Ollama пока без vision в этой интеграции). + +### Проактивный планировщик + +- "Напомни через 5 минут выключить кофеварку" → one-shot. +- "Каждые 2 часа напоминай попить воды" → interval. +- "Каждый день в 9:00 делай брифинг" → daily. +- "Отмени напоминание про воду" / "очисти расписание" / "что у меня запланировано". +- Фоновый поток (тик каждые 30 сек). Сохраняется в `/schedule.json`. + +### Voice macros (VoiceAttack-style) + +- "Запиши макрос работа" → начинает запись. +- Любые следующие команды (открой браузер, режим работа, ...) копятся. +- "Сохрани макрос" → персистится. +- "Запусти макрос работа" → проигрывает каждый шаг с интервалом 800мс. +- Список / удаление / отмена. + +### Утилиты + +| Команда | Что делает | +|---|---| +| "Найди в гугле X" | DuckDuckGo Instant Answer + LLM-перессказ | +| "Википедия X" / "что такое X" | Wikipedia REST API (ru→en fallback) + LLM-перевод | +| "Сколько 1000 долларов в рублях" | CBR-XML курс + конвертация | +| "Сколько Сбер" | MOEX биржа, 22 тикера в карте | +| "Переведи буфер" | Clipboard → LLM → обратно в clipboard | +| "Напоминай мне пить воду" | Habit nudge: каждые 2 часа | +| "Напоминай размяться" | Каждые 50 минут | +| "Напоминай отдыхать глазам" | 20-20-20 rule, каждые 20 минут | +| "Запусти помодоро" | 25/5 циклы с голосовыми напоминаниями | +| "Запиши настроение 7" / "как прошла неделя" | Mood log + LLM-сводка | +| "Утренний брифинг" / "настрой утренний брифинг на 9:00" | Время+профиль+задачи+память+LLM-мотивашка | +| "Укажи проект C:\..." / "что делает функция X" | Codebase Q&A — LLM по локальной кодобазе | +| "Открытые пиары" / "разбери последний PR" | gh CLI + LLM-ревью | +| "Пауза" / "следующий трек" | Windows media keys (Spotify / YouTube / Foobar) | +| "Диагностика" / "доложи о себе" | Состояние всех бэкендов одной строкой | + +Всего паков: **85** (`resources/commands/*/command.toml`) + +плагины пользователя из `%APPDATA%\com.priler.jarvis\plugins\`. +Полный список: GUI → /commands (с поиском и фильтрами). + +### Плагины + +Голосовые команды, которые лежат **вне** проекта. Каждый плагин — +папка с тем же `command.toml` + Lua-скриптами: + +``` +%APPDATA%\com.priler.jarvis\plugins\<имя>\ + command.toml (тот же формат, что и встроенные паки) + *.lua (скрипты, на которые ссылается toml) + disabled (опционально — пустой файл, отключает плагин) +``` + +Подгружается при следующем старте `jarvis-app.exe`. Sandbox принудительно +понижается до `standard` — плагин не может попросить `full` (доступ к `os`). +GUI → /plugins показывает список, флажки enable/disable, кнопку «Открыть папку». + +### Своё кодовое слово + +GUI → /wake-trainer — мастер: запишите 5–30 примеров своего голоса, +обучите персональную `.rpw`-модель, выберите её в Настройках, перезапустите +ассистент. Файл — `%APPDATA%\com.priler.jarvis\wake_words\<имя>.rpw`. + +## Быстрый старт + +```powershell +# 1. Клонировать +git clone https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust.git C:\Jarvis\rust +cd C:\Jarvis\rust + +# 2. Поставить нейронный TTS (один раз, ~90 МБ) +pwsh tools/piper/install.ps1 + +# 3a. Облачный LLM — получи токен на https://console.groq.com/keys +echo "GROQ_TOKEN=gsk_..." > dev.env + +# 3b. ИЛИ локальный LLM +# Скачай https://ollama.com/download, выполни: +# ollama pull qwen2.5:3b +# (Jarvis сам подхватит, если GROQ_TOKEN отсутствует) + +# 4. Сборка +cd frontend && npm install && npm run build && cd .. +cargo build --release -p jarvis-app -p jarvis-gui + +# 5. Запуск +target\release\jarvis-app.exe # фоновый демон с микрофоном +target\release\jarvis-gui.exe # GUI для управления +``` + +Подробнее: см. секцию "Сборка" ниже. + +## Конфигурация (env vars) + +Все JARVIS_* / GROQ_* / OLLAMA_* переменные документированы в +`crates/jarvis-core/src/runtime_config.rs`. Ключевые: + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `GROQ_TOKEN` | — | Токен для облачного LLM (без него — Ollama / выкл) | +| `GROQ_MODEL` | `llama-3.3-70b-versatile` | Groq-модель | +| `GROQ_VISION_MODEL` | `llama-3.2-11b-vision-preview` | Vision-модель для "что на экране" | +| `OLLAMA_BASE_URL` | `http://localhost:11434/v1` | Адрес Ollama | +| `OLLAMA_MODEL` | `qwen2.5:3b` | Локальная модель (любая через `ollama pull`) | +| `JARVIS_LLM` | auto | `groq` / `ollama` / пусто (auto-detect) | +| `JARVIS_TTS` | auto | `sapi` / `piper` / `silero` / пусто (auto) | +| `JARVIS_TTS_PIPER_BIN` | автопоиск | Путь к piper.exe | +| `JARVIS_TTS_PIPER_VOICE` | автопоиск | Путь к голосу .onnx | +| `JARVIS_LLM_ROUTER` | `1` | Включить agentic router | +| `JARVIS_LLM_ROUTER_THRESHOLD` | `0.55` | Confidence threshold | +| `JARVIS_LLM_TTS` | `true` | Озвучивать LLM-ответ | + +Файл `dev.env` подхватывается автоматически (jarvis-app ищет от exe вверх по 5 +уровням), так что переменные можно держать в одном месте. + +Персистентные настройки (`voice`, `microphone`, `vosk_model`, `llm_backend`, +`tts_backend`, ...) хранятся в `/settings.json` и правятся +через GUI Settings или Lua `jarvis.settings.set(key, val)`. + +## Сборка + +Требования: + +- Rust 1.93+ (stable MSVC). +- Node 20+ и npm для фронтенда. +- MSVC build tools (`vcvars64.bat`). VS 2022 / 2026 / 2026 — любая. +- Python 3 (только для `post_build.py`, опционально). +- Vosk/Porcupine/PvRecorder DLL'и в `lib/windows/amd64/` (уже в репо). + +Команды: + +```powershell +# Один раз: загрузить MSVC окружение +$vc = "C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat" +cmd /c "`"$vc`" >NUL && set" | % { + if ($_ -match '^([^=]+)=(.*)$') { + [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process') + } +} + +# Собрать фронтенд +cd frontend +npm install +npm run build +cd .. + +# Workspace +cargo build --release -p jarvis-app -p jarvis-gui +``` + +Холодная сборка ~10 мин (ONNX runtime, aws-lc-rs, tauri). Инкремент ~30 сек. + +`jarvis-gui/build.rs` сам зовёт `npm run build` если фронтенд устарел, так что +для итераций по UI достаточно `cargo build -p jarvis-gui`. + +## Тесты + +```powershell +cargo test -p jarvis-core --lib # все unit-тесты +cargo test -p jarvis-core --lib scheduler:: # фильтр по модулю +``` + +Текущее покрытие: **55 unit-тестов**, см. таблицу в ARCHITECTURE.md. + +## Структура + +``` +crates/ + jarvis-core library — STT, wake-word, TTS, LLM, intent, commands, IPC, + Lua sandbox, scheduler, memory, profiles, macros, vision + jarvis-app daemon бинарь — микрофон, listening loop, dispatcher, tray, IPC + jarvis-gui Tauri + Svelte GUI — settings, commands list, footer chips + jarvis-cli debug CLI (classify/execute/list) + +frontend/ Vite + Svelte UI для jarvis-gui +resources/ + commands/ 59 voice-command packs (Lua скрипты + TOML) + models/ ONNX (intent classifier, GLiNER) — Git LFS + vosk/ Vosk модели (small-ru, small-en, ...) + voices/ пресеты звуков реакций (greet/ok/error/...) + +tools/ + piper/ Piper TTS бинарник + голоса (.onnx) — устанавливаются install.ps1 + silero/ Python helper для Silero TTS (опционально) + +ARCHITECTURE.md — для контрибьюторов: data flow, рецепты, тесты. +``` + +## Голосовой workflow + +Поднимаешь `jarvis-app.exe`, говоришь: + +``` +"Джарвис" ─ wake-word + ↓ играет "reply" звук +"Какая сегодня погода?" + ↓ STT (Vosk) распознаёт + ↓ Intent classifier (MiniLM ONNX) пробует найти команду + ↓ Levenshtein fallback + ↓ Agentic LLM router (если включён) + ↓ LLM fallback (chat) + ↓ Озвучивает ответ +"Какая температура завтра?" + ↓ 30-сек grace window — без повторного wake-word + ↓ LLM помнит контекст разговора +``` + +После каждой команды есть 30-секундное окно для follow-up без повторного +"Джарвис" (`CONVERSATION_GRACE_MS`). ## Это форк Оригинальный автор — Abraham Tugalov (Priler). Апстрим: . Лицензия сохранена: **CC BY-NC-SA 4.0** (см. `LICENSE.txt`). -Атрибуция в `Cargo.toml` и `voice.toml` пакетов озвучки не изменена. -## Что отличается от апстрима - -- Обновлён список авторов в `Cargo.toml` (добавлен `Bossiara13 (fork)`, оригинал сохранён). -- README переписан и отражает фактическую архитектуру (апстримный README называет проект "Tauri+Svelte", что давно не соответствует действительности — это workspace из 4-х крейтов). -- Отсутствующие в апстриме ONNX-модели (`all-MiniLM-L6-v2`, `paraphrase-multilingual-MiniLM-L12-v2-onnx-Q`) подтянуты через Git LFS из HuggingFace (Qdrant) и запушены в форк. - -## Структура репозитория - -Cargo workspace из четырёх крейтов: - -| Крейт | Назначение | -|----------------|----------------------------------------------------------------------------| -| `jarvis-core` | Библиотека: конфиг, intent, STT, wake-word, аудио, Lua-бэкенд, i18n. | -| `jarvis-app` | Бинарь-«демон»: собирает всё вместе, tray, IPC. | -| `jarvis-gui` | Tauri-приложение (использует `frontend/dist/client`). | -| `jarvis-cli` | CLI для отладки: классификация intent, список команд, dump конфига. | - -Прочее: - -- `frontend/` — Vite + Svelte UI для `jarvis-gui`. Собирается отдельно. -- `lib/windows/amd64/` — нативные DLL/LIB для Vosk, Porcupine, PvRecorder. -- `resources/` — голоса, модели, конфиги по умолчанию. ONNX-модели хранятся в Git LFS. -- `post_build.py` — постпроцессинг артефактов сборки (Python 3). - -## Сборка - -Требования: - -- Rust 1.93+ (собирается на stable MSVC). -- Node 24+ и npm — для фронтенда. -- Python 3 — для `post_build.py`. -- MSVC build tools (Windows, x64). -- Установленные `libvosk.lib`, `libpv_porcupine.dll`, `libpv_recorder.dll` в `lib/windows/amd64/` (уже в репозитории). - -Перед сборкой `jarvis-gui` нужно собрать фронтенд: - -```bash -cd frontend -npm install -npm run build -cd .. -``` - -Затем workspace: - -```bash -cargo build --workspace -``` - -Холодная сборка занимает около 10 минут (ONNX runtime, aws-lc-rs, tauri). - -## Статус сборки в этом форке - -На моей машине (`cargo build --workspace`, stable MSVC) итог: - -- `jarvis-core` — собрался (1 warning, unused import). -- `jarvis-app` — собрался, бинарник `target/debug/jarvis-app.exe` создан. -- `jarvis-cli` — **падает на линковке**: `LNK1181: cannot open input file "libvosk.lib"`. - Причина: у `jarvis-cli` нет своего `build.rs`, а `.cargo/config.toml` с `rustc-link-search` лежит только внутри `crates/jarvis-app/` и не подтягивается для `jarvis-cli`. Лечится либо добавлением такого же `build.rs` в `crates/jarvis-cli/`, либо вынесением `config.toml` в корень. Сознательно не трогал — фикс выходит за рамки рефакторинга (v0.0.1-import фиксирует поведение апстрима как есть). -- `jarvis-gui` — падает в `tauri::generate_context!()`: `frontendDist = "../../frontend/dist/client"` не существует. Это ожидаемо, если не запустить `npm run build` в `frontend/` заранее (см. секцию «Сборка»). - -Запуск уже собранного: - -```bash -./target/debug/jarvis-app.exe -``` - -Для CLI (`jarvis-cli --help`, команды `classify`, `execute`, `list`, `phrases`) нужно сначала починить линковку Vosk (см. выше). - -## LLM (Groq) - -В `jarvis-core` есть модуль `llm` — блокирующий клиент для OpenAI-совместимого эндпоинта chat completions. По умолчанию настроен на Groq. Используется через фиче-флаг `llm` (включён в дефолтный набор `jarvis_app`, также подтянут в `jarvis-cli`). - -Переменные окружения: - -| Переменная | Обязательна | Значение по умолчанию | -|-----------------|-------------|----------------------------------------| -| `GROQ_TOKEN` | да | — | -| `GROQ_BASE_URL` | нет | `https://api.groq.com/openai/v1` | -| `GROQ_MODEL` | нет | `llama-3.3-70b-versatile` | - -Быстрая проверка через CLI: - -```bash -set GROQ_TOKEN=gsk_... -jarvis-cli ask "скажи привет одной фразой" -``` - -Ответ печатается в stdout. Без `GROQ_TOKEN` команда завершится с кодом 2 и сообщением об ошибке. При ошибке API — код 1 и тело ответа. - -Программное использование из Rust: - -```rust -use jarvis_core::llm::{LlmClient, ChatMessage}; - -let client = LlmClient::from_env()?; -let reply = client.complete(&[ChatMessage::user("привет")], 256)?; -println!("{}", reply); -``` - -### Подключение к голосовому циклу - -Помимо CLI, LLM подключён напрямую в `jarvis-app`. Логика в `crates/jarvis-app/src/llm_fallback.rs`: - -- При старте `jarvis-app` пытается прочитать `GROQ_TOKEN`. Если переменной нет — фоллбэк отключается, в лог пишется warning, голосовые команды продолжают работать как раньше. -- Распознанная фраза (как из микрофона, так и из текстовой панели GUI) проверяется на префиксы-триггеры из `config::get_llm_trigger_phrases` (для `ru`/`ua`: `скажи`, `ответь`, `произнеси`; для `en`: `say`, `tell`, `answer`). -- Если триггер найден — остаток фразы уходит в `LlmClient::complete()`, ответ публикуется в IPC как `IpcEvent::LlmReply { text }` (UI/GUI слушает этот ивент и проговаривает текст уже на своей стороне), а звук-«ок» проигрывается из текущего голосового пресета. -- История разговора хранится в `ConversationHistory` с потолком `LLM_DEFAULT_MAX_HISTORY = 8` ходов; system-prompt всегда сохраняется при вытеснении старых ходов. -- При сетевой/API-ошибке последний user-turn убирается из истории, в IPC уходит `LlmReply` с короткой русской фразой («Не могу связаться с сервером, сэр.»), играется звук-«error». Голосовой цикл не падает. - -Системный промпт (русский) описывает J.A.R.V.I.S. как британского дворецкого Тони Старка — короткие реплики (1–3 предложения), обращение «сэр», без излишней цензуры. Меняется в `config::LLM_SYSTEM_PROMPT_RU`. - -## VAD: умное завершение команды по тишине - -После срабатывания wake-word `jarvis-app` слушает команду. Раньше окно закрывалось либо по таймауту Vosk, либо по жёсткому пределу из `config::CMS_WAIT_DELAY` — короткие команды («стоп», «громче») всё равно ждали несколько секунд тишины. - -Теперь окно закрывается, как только пользователь замолчал. Реализовано через `webrtc-vad` (чистый Rust, тот же алгоритм, что в Python-версии). Это **паритет с Python v0.2.0**, где использовался `webrtcvad` с теми же параметрами. - -Логика (state-машина в `crates/jarvis-core/src/audio_processing/vad/listen_window.rs`): - -- Кадр VAD: 30 мс при 16 кГц = 480 сэмплов = 960 байт (mono i16). Кадры с микрофона (по 512 сэмплов от `pv_recorder`) аккумулируются и режутся на VAD-кадры адаптером `WebRtcVad::push_samples`. -- Каждый VAD-кадр учитывается в счётчиках `speech_ms` / `silence_ms`. -- Окно закрывается, когда `silence_ms >= VAD_COMMAND_END_SILENCE_MS` И `speech_ms >= VAD_COMMAND_MIN_SPEECH_MS`. -- До истечения `VAD_COMMAND_MIN_LISTEN_MS` окно не закрывается — пользователю даётся время начать говорить. -- При достижении `VAD_COMMAND_MAX_LISTEN_MS` срабатывает hard-cap и управление возвращается в режим ожидания wake-word. -- На событии «закрыть окно» вызывается `stt::finalize_speech()` — Vosk форсированно отдаёт финальный результат, не дожидаясь собственного таймаута. - -Параметры (`crates/jarvis-core/src/config.rs`): - -| Константа | Значение | Назначение | -|---------------------------------|----------|-------------------------------------------------------------| -| `VAD_AGGRESSIVENESS` | `2` | Уровень WebRTC VAD: 0 — Quality, 3 — VeryAggressive. | -| `VAD_COMMAND_END_SILENCE_MS` | `1200` | Сколько тишины подряд считать «команда закончилась». | -| `VAD_COMMAND_MIN_SPEECH_MS` | `500` | Минимум речи в окне — иначе закрытие игнорируется. | -| `VAD_COMMAND_MIN_LISTEN_MS` | `1000` | Минимальная длительность окна (страховка от ранних закрытий)| -| `VAD_COMMAND_MAX_LISTEN_MS` | `15000` | Жёсткий потолок окна. | - -На короткие команды отзыв стал заметно быстрее (мы выходим на распознавание сразу после паузы пользователя, а не по 5-секундному порогу Vosk). - -## Лицензия - -Creative Commons **Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0). -Полный текст — в `LICENSE.txt`. Атрибуция оригинального автора (Abraham Tugalov) сохранена. - -В `Cargo.toml` декларирован `license = "GPL-3.0-only"` — это несоответствие унаследовано от апстрима и не правилось, чтобы не расходиться с upstream-конфигом. Приоритет имеет `LICENSE.txt`. +В `Cargo.toml` декларирован `license = "GPL-3.0-only"` — это несоответствие +унаследовано от апстрима и не правилось, чтобы не расходиться с upstream-конфигом. +Приоритет имеет `LICENSE.txt`. ## Python-версия -Старая версия ассистента была на Python. -Последний коммит с Python-кодом в апстриме — [943efbf](https://github.com/Priler/jarvis/tree/943efbfbdb8aeb5889fa5e2dc7348ca4ea0b81df). +Старая версия ассистента была на Python. Последний коммит с Python-кодом в +апстриме — [943efbf](https://github.com/Priler/jarvis/tree/943efbfbdb8aeb5889fa5e2dc7348ca4ea0b81df). +Параллельный Python-форк живёт в отдельном репо +([J.A.R.V.I.S-py](https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-py)) — там же, +но **17 новых паков из Rust-форка пока туда не зеркалированы** (TODO). + +## Поддерживаемость + +- `runtime_config.rs` — все env vars в одном файле с doc-комментами. +- `tts/` — trait-based, добавить новый бэкенд = реализовать `TtsBackend`. +- `llm/mod.rs` — shared global client, hot-swap через `swap_to(LlmBackend)`. +- `jarvis.cmd.{ok/error/not_found}` — boilerplate-killer для Lua-паков. +- 55 unit-тестов прикрывают всё, что не требует мика/динамика/сети. +- ARCHITECTURE.md содержит рецепты "как добавить X". + +## Что в roadmap (что ещё хочется) + +- **faster-whisper STT** рядом с Vosk для длинных LLM-фраз. +- **Реальные Win32 макросы** (keyboard + mouse hooks), не только voice replay. +- **Spotify Web API** через OAuth — "что играет", "сохрани в плейлист". +- **Outlook COM** — голосовая почта / календарь. +- **GUI /macros + /scheduler страницы** — управление без голоса. +- **Python parity** для 17 новых паков. + +См. `~/.claude/projects/C--Jarvis/memory/project_jarvis_roadmap.md` (если ты +— Claude помогаешь автору) или открой issue. + +## Лицензия + +CC BY-NC-SA 4.0 — Creative Commons **Attribution-NonCommercial-ShareAlike**. +Полный текст: `LICENSE.txt`. Атрибуция Abraham Tugalov сохранена. diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..44a7278 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,307 @@ +# Voice command reference + +Все голосовые команды J.A.R.V.I.S. (текущее состояние, **98 паков**). +Каждая команда — это фраза, которую можно произнести после wake-word ("Джарвис"). + +> Языки: **RU + EN** (украинский поддерживался до 2026-05-16, в текущей версии удалён). + +Группы: + +- [Управление ассистентом](#управление-ассистентом) +- [LLM / мозг](#llm--мозг) +- [Память / профили / macros](#память--профили--macros) +- [Расписание / напоминания / привычки](#расписание--напоминания--привычки) +- [Окна / приложения / процессы](#окна--приложения--процессы) +- [Аудио / медиа](#аудио--медиа) +- [Система / устройство](#система--устройство) +- [Файлы / буфер / заметки](#файлы--буфер--заметки) +- [Информация из интернета](#информация-из-интернета) +- [Утилиты-калькуляторы](#утилиты-калькуляторы) +- [Разработка](#разработка) +- [Развлечения](#развлечения) +- [Скриншоты / экран](#скриншоты--экран) + +После каждой команды есть **30-секундное grace-окно** для follow-up без повторного "Джарвис". +Конфигурируется через `CONVERSATION_GRACE_MS`. + +--- + +## Управление ассистентом + +| Команда (RU) | Что делает | +|---|---| +| "Помощь" / "что ты умеешь" | Перечисляет категории возможностей | +| "Расскажи о себе" / "кто ты такой" | Краткое представление (LLM варьирует) | +| "Сколько команд знаешь" | Health snapshot — все активные бэкенды + счётчики | +| "Диагностика" / "доложи о себе" | Одной строкой: TTS, LLM, профиль, памяти/задач | +| "Повтори за мной X" | Эхо — для теста микрофона + TTS | +| "Что я сказал" | Что услышал из последней фразы | +| "Стоп" / "хватит" / "прекрати" | Прерывает текущую команду | +| "Сбрось контекст" / "забудь разговор" | Очищает историю LLM | +| "Повтори последнее" / "повтори ответ" | Озвучивает последний ответ LLM | + +## LLM / мозг + +| Команда | Что делает | +|---|---| +| "Переключись на локальный" / "перейди на оллама" | Hot-swap на Ollama (persistent в БД) | +| "Переключись на облако" / "используй грок" | Hot-swap на Groq | +| "Какой у тебя мозг" / "облако или локально" | Текущий backend | + +Любая фраза без распознанной команды → LLM router (если включён) → если нет матча → LLM chat fallback. + +## Память / профили / macros + +| Команда | Что делает | +|---|---| +| "Запомни что я люблю чай улун" | Сохраняет факт | +| "Что ты помнишь про чай" | Substring-поиск по памяти | +| "Что ты знаешь обо мне" | Все факты | +| "Забудь про X" | Удалить факт | +| "Режим работа" / "режим игра" / "режим сон" / "режим за рулём" / "обычный режим" | Профиль (5 встроенных) | +| "Какой сейчас режим" | Активный профиль | +| "Запиши макрос имя" | Начать запись макроса | +| "Сохрани макрос" | Завершить запись и сохранить | +| "Отмени макрос" | Отменить запись | +| "Запусти макрос имя" | Воспроизвести (с интервалом 800мс) | +| "Какие у меня макросы" / "удали макрос имя" | Управление списком | + +Также: GUI страницы `/memory` (добавить/забыть факты), `/macros` (запуск/удаление), `/scheduler` (отмена задач). + +## Расписание / напоминания / привычки + +| Команда | Что делает | +|---|---| +| "Напомни через 5 минут выключить кофеварку" | One-shot reminder | +| "Напомни в 18:00 забрать ребёнка" | Today at HH:MM | +| "Каждые 2 часа напоминай попить воды" | Interval | +| "Каждый день в 9:00 делай брифинг" | Daily | +| "Что у меня запланировано" / "очисти расписание" | Управление | +| "Отмени напоминание про воду" | Substring-match remove | +| "Запусти помодоро" / "стоп помодоро" | 25/5 циклы | +| "Напоминай мне пить воду" | Каждые 2 часа | +| "Напоминай размяться" | Каждые 50 минут | +| "Напоминай отдыхать глазам" | 20-20-20 (каждые 20 мин) | +| "Отключи все привычки" | Стоп всех habit_nudge задач | +| "Настрой утренний брифинг на 9:00" | Daily briefing в указанное время | +| "Утренний брифинг" | Запустить брифинг сейчас | +| "Отключи утренний брифинг" | Снять daily задачу | + +## Окна / приложения / процессы + +| Команда | Что делает | +|---|---| +| "Открой Telegram" / "запусти Steam" / "Spotify" | Через `apps/` пак (имя в config) | +| "Закрой Telegram" / "убей процесс X" | Process kill | +| "Сверни всё" / "разверни окно" / "переключи окно" | Window manager | +| "Какие открыты окна" | Список Z-order | +| "Сверни активное" / "максимизируй" | Action на foreground | +| "Открой папку загрузок" / "файлы" / "проводник" | Explorer | + +## Аудио / медиа + +| Команда | Что делает | +|---|---| +| "Громче" / "тише" / "громкость на 30" | Volume up/down/set | +| "Заглуши" / "сними заглушку" | Mute toggle | +| "Какая громкость" | Текущий уровень | +| "Открой панель звука" | Sound panel | +| "Включи Realtek" / "переключи звук на Headphones" | Output device swap | +| "Пауза" / "плей" / "следующий трек" / "предыдущий трек" / "стоп музыка" | Media keys — Spotify / YouTube / Foobar / Yandex Music | +| "Запиши голосовую заметку" | (см. notes/) | +| "Выключи музыку через 30 минут" | Sleep timer для медиа | + +## Система / устройство + +| Команда | Что делает | +|---|---| +| "Сколько процессор грузится" / "сколько RAM" / "температура" | Sysinfo | +| "Сколько заряда" / "заряд батареи" | Battery % | +| "Сколько свободно на диске" / "сколько на диске C" | PowerShell Get-PSDrive | +| "Какие у меня диски" | C: D: E: со свободным местом | +| "Какая сеть" / "какой WiFi" | Net SSID via netsh | +| "Мой IP" / "локальный IP" | Get-NetIPAddress | +| "Внешний IP" | api.ipify.org | +| "Прибавь яркость" / "тусклее" / "яркость 70" | Brightness via WMI | +| "Тёмная тема" / "светлая тема" | Windows theme | +| "Заблокируй компьютер" | LockWorkStation | +| "Выключи компьютер" / "перезагрузи" / "ребутни" / "сон" / "гибернация" / "выйди из системы" / "отмени выключение" | Power management | +| "Выключи компьютер через 30 минут" | Sleep timer для системы | +| "Отмени таймер выключения" | shutdown /a + scheduler clear | + +## Файлы / буфер / заметки + +| Команда | Что делает | +|---|---| +| "Найди файл report" / "поищи documents" | File search | +| "Запиши заметку купить хлеб" | Append в notes file | +| "Покажи заметки" / "что я записал" | Открывает notes | +| "Прочитай буфер" / "что в буфере" | Clipboard contents speak | +| "Переведи буфер на английский" | LLM перевод + replace clipboard | + +## Информация из интернета + +| Команда | Что делает | +|---|---| +| "Какая погода" / "погода завтра" / "что с погодой" | Weather pack | +| "Курс доллара" / "курс юаня" | CBR rates | +| "Сколько 1000 долларов в рублях" | Currency convert | +| "Сколько Сбер" / "сколько Газпром" | MOEX котировки (22 тикера) | +| "Сколько биткоин" / "сколько эфир" | Crypto | +| "Новости" / "что нового в мире" | News RSS | +| "Найди в гугле X" / "загугли X" | DuckDuckGo Instant + LLM | +| "Википедия X" / "что такое X" / "кто такой Y" | Wikipedia REST | +| "Открыть Google поиск X" | Websearch (open browser) | +| "Переведи на английский X" | Translate | + +## Утилиты-калькуляторы + +| Команда | Что делает | +|---|---| +| "Посчитай 2 плюс 2" / "сколько будет 10 в степени 3" | Math | +| "Переведи 100 метров в футы" | Length convert | +| "70 кг в фунты" | Weight convert | +| "100 цельсий в фаренгейт" | Temperature convert | +| "100 км в час в мили в час" | Speed convert | +| "Сколько дней до нового года" | Date math (8 марта, 9 мая, Рождество, Новый год + любая дата) | +| "Какой сегодня день недели" | Zeller's congruence | +| "Время" / "сколько времени" / "какое сегодня число" | Date query | +| "Подбрось монету" / "орёл или решка" | Coin flip | +| "Сгенерируй пароль 16 символов" | Secure password → clipboard | +| "Сгенерируй UUID" | v4 UUID → clipboard | +| "Брось кубик" / "брось два кубика" | Dice | +| "Выбери случайно из X или Y или Z" | Random choice | +| "Запусти секундомер" / "сколько прошло" / "стоп секундомер" | Stopwatch | +| "Прибавь счётчик" / "сколько на счётчике" / "сбрось счётчик" | Counter | +| "Как пишется X" | Spelling | + +## Разработка + +| Команда | Что делает | +|---|---| +| "Укажи проект C:\..." | Codebase Q&A: установить root | +| "Какой проект сейчас" | Текущий root | +| "Что делает функция X" / "найди в коде Y" / "объясни код" | Walks codebase → LLM | +| "Текущий репо owner/repo" | GitHub: установить repo | +| "Какие пиары" / "открытые пиары" | List PRs via gh CLI | +| "Разбери последний пиар" | LLM-ревью PR (gh pr view) | +| "Сгенерируй код для X" | Code generation pack | +| "Открой Cursor" / "VS Code" | apps/ | + +## Развлечения + +| Команда | Что делает | +|---|---| +| "Расскажи анекдот" / "пошути" / "скажи фразу" / "тостер" | Fun pack (jokes) | +| "Запиши настроение 7" / "сегодня мне грустно" | Mood log | +| "Как прошла неделя" / "сводка настроения" | Mood recap via LLM | +| "Запусти Steam игру X" / "поиграй в Y" | Games pack | + +## Скриншоты / экран + +| Команда | Что делает | +|---|---| +| "Что на экране" / "опиши экран" | Vision LLM описание | +| "Прочитай ошибку" | Vision LLM: ищет stack trace, объясняет | +| "Сделай скриншот" / "скрин в буфер" | Screenshot → clipboard | +| "Сохрани скриншот" | Screenshot → ~/Pictures/Screenshots/ | +| "OCR экрана" / "прочитай текст" | OCR (existing pack) | +| "Суммаризируй экран" | Summarize visible window | + +--- + +## Wave 4-8 (новое — личность, рутины, режимы) + +| Команда | Что делает | +|---|---| +| **Личность / banter** | | +| "Привет Джарвис" / "Доброе утро" | Время-чувствительные ответы из ~30 вариантов | +| "Спасибо" / "Ты молодец" | Варьирующиеся «батлерские» ответы | +| "Как дела Джарвис" | Включает живые цифры из памяти + профиля | +| "Процитируй Тони" | Случайная цитата Stark/JARVIS (15+ вариантов) | +| "Скажи что-нибудь интересное" / "Скучно мне" | Принудительная idle-banter реплика | +| "Помолчи" / "Можешь говорить" | Pause/resume фонового banter | +| **Память / разговор** | | +| "О чём мы говорили" | LLM-сжатие последних ~12 реплик | +| "Повтори" / "Не расслышал" | Перепроизнести последний ответ | +| "Сколько фактов помнишь" | Размер долговременной памяти | +| "Покажи что помнишь" | Список ключей в памяти | +| "Точно забудь всё" | Полное стирание памяти (с защитой) | +| **Режимы и рутины** | | +| "Режим фокуса" / "Focus mode" | Профиль work + Focus Assist + стретч через 50 мин | +| "Выключи фокус" | Откат | +| "Спокойной ночи" | Профиль sleep + отмена одноразовых таймеров | +| "Доброе утро" | Профиль default + обзор расписания | +| "Иду за кофе" / "Coffee break" | Пауза banter + чек-ин через 5 минут | +| **Утилиты / новые** | | +| "Сколько будет 234 на 567" | Offline-парсер (без LLM, instant) | +| "Что такое квантовая запутанность" | DDG Instant Answer (без ключа) | +| "Переведи буфер на немецкий" | Translate clipboard с озвучкой в немецком SAPI voice | +| "Поставь чайник" / "Варю пасту" | Cooking timer с пресетами на 16+ блюд | +| "На чём я остановился" | Recap из памяти + расписания + последнего LLM-ответа | +| "Расскажи про железного человека" | Случайный MCU-факт | +| "Разбуди сервер" / "Wake on LAN" | Magic packet на MAC из памяти (`wol_server`) | +| **Outlook (требует Outlook)** | | +| "Сколько у меня непрочитанных писем" | Outlook COM → счётчик inbox | +| "Прочитай последнее письмо" | From + Subject + 200 chars body | +| "Отправь письмо [Имя]" | Body = clipboard, через GAL lookup | +| "Коротко расскажи про инбокс" | LLM-сводка последних 5 unread | +| **Time tracker** | | +| "Начни отсчёт" / "Закончи отсчёт" | Сессия учёта рабочего времени | +| "Сколько я работаю сегодня" | Сумма открытых + закрытых сессий за календарный день | +| "Сколько на этой неделе" | Сумма за последние 7 дней | +| "Сбрось трекер" | Стереть сегодняшние записи | +| **Кастомное wake-word** | | +| GUI → /wake-trainer | Мастер: запись 5–30 сэмплов → rustpotter .rpw | +| **Плагины** | | +| GUI → /plugins | Toggle/Open Folder для `%APPDATA%/com.priler.jarvis/plugins/` | + +## Как добавить свою команду + +Скопируй `resources/commands/echo/` как шаблон, отредактируй `command.toml` +(id, phrases) и `*.lua` (логика). Рекомендуемая структура — см. +[ARCHITECTURE.md → Recommended pack structure](ARCHITECTURE.md#recommended-pack-structure). + +Минимум: + +```lua +-- thing.lua +local phrase = (jarvis.context.phrase or ""):lower() +local body = jarvis.text.strip_trigger(phrase, { "сделай штуку" }) +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + return jarvis.cmd.error("Что именно?") +end + +-- ... do work ... + +return jarvis.cmd.ok("Готово.") +``` + +Затем `cargo test -p jarvis-core --lib commands::tests` — он автоматически +проверит что TOML парсится и Lua-скрипт существует. + +## Голосовое vs GUI + +GUI страницы — для случаев когда голосом неудобно: + +- `/commands` — все 67 паков с поиском (для discovery) +- `/macros` — список с кнопкой "Запустить" +- `/scheduler` — задачи с auto-poll каждые 5 сек +- `/memory` — добавление/удаление фактов с фильтром +- `/settings → AI Backends` — переключение LLM/TTS через dropdown + +## Что-то не работает? + +1. **Голос не распознаётся** — проверь микрофон в Settings → Devices. Скажи + "повтори за мной привет" — если эхо проходит, проблема не в STT. +2. **Не слышу ответ** — проверь TTS backend в Settings → AI Backends. Скажи + "проверка эхо" — должен быть слышен голос. Если нет, проверь динамики + + попробуй `JARVIS_TTS=sapi`. +3. **"Не могу связаться с сервером"** — LLM unreachable. Если Groq — + проверь GROQ_TOKEN и интернет. Если Ollama — `ollama serve` запущен? +4. **Команда не найдена** — посмотри `/commands` в GUI, поищи фразу. Если + фразы похожи но не та — agentic router сделает попытку, или подскажет. +5. **Что-то странное в логах** — `/jarvis.log` (путь видно + в первых строках `jarvis-app` stdout). diff --git a/crates/jarvis-app/Cargo.toml b/crates/jarvis-app/Cargo.toml index 78353b5..76bff45 100644 --- a/crates/jarvis-app/Cargo.toml +++ b/crates/jarvis-app/Cargo.toml @@ -11,12 +11,15 @@ jarvis-core = { path = "../jarvis-core", features = ["intent"] } once_cell.workspace = true log.workspace = true simple-log = "2.4" +dotenvy.workspace = true tray-icon = "0.21" winit = "0.30" image.workspace = true platform-dirs.workspace = true rand.workspace = true parking_lot.workspace = true +serde.workspace = true +serde_json.workspace = true tokio = { version = "1", features = ["rt-multi-thread"] } @@ -26,7 +29,11 @@ features = [] [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["winuser"] } +winrt-notification.workspace = true [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" -glib = "0.21.5" \ No newline at end of file +glib = "0.21.5" + +[target.'cfg(target_os = "windows")'.build-dependencies] +embed-resource = "3" \ No newline at end of file diff --git a/crates/jarvis-app/app.manifest b/crates/jarvis-app/app.manifest new file mode 100644 index 0000000..de8adb1 --- /dev/null +++ b/crates/jarvis-app/app.manifest @@ -0,0 +1,60 @@ + + + + J.A.R.V.I.S. voice assistant daemon + + + + + + + + + + + + + + + + + + + + + + + + + true/pm + PerMonitorV2,PerMonitor + UTF-8 + + + + + + + + + + + diff --git a/crates/jarvis-app/app.manifest.rc b/crates/jarvis-app/app.manifest.rc new file mode 100644 index 0000000..bbb1850 --- /dev/null +++ b/crates/jarvis-app/app.manifest.rc @@ -0,0 +1,4 @@ +#define RT_MANIFEST 24 +#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1 + +CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "app.manifest" diff --git a/crates/jarvis-app/build.rs b/crates/jarvis-app/build.rs index 30a6d3f..e7a70a7 100644 --- a/crates/jarvis-app/build.rs +++ b/crates/jarvis-app/build.rs @@ -1,10 +1,18 @@ fn main() { - // link to Vosk lib - // println!("cargo:rustc-link-lib=libvosk.dll"); - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); let lib_path = std::path::Path::new(&manifest_dir) .join("..\\..\\lib\\windows\\amd64"); - + println!("cargo:rustc-link-search=native={}", lib_path.display()); + + // Embed a Common-Controls v6 manifest on Windows so transitive imports + // pulled in by tray-icon / winit (TaskDialogIndirect, themed controls, + // per-monitor DPI) resolve against comctl32 v6. Without this the exe + // crashes at load with "TaskDialogIndirect entry point not found". + #[cfg(target_os = "windows")] + { + println!("cargo:rerun-if-changed=app.manifest"); + println!("cargo:rerun-if-changed=app.manifest.rc"); + let _ = embed_resource::compile("app.manifest.rc", embed_resource::NONE); + } } diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index 492d4cc..06db2d9 100644 --- a/crates/jarvis-app/src/app.rs +++ b/crates/jarvis-app/src/app.rs @@ -2,7 +2,6 @@ use std::sync::mpsc::Receiver; use std::time::SystemTime; use jarvis_core::{audio_buffer::AudioRingBuffer, audio_processing, audio_processing::vad::listen_window::{ListenWindow, WindowDecision}, audio_processing::vad::webrtc::WebRtcVad, commands, config, listener, recorder, stt, COMMANDS_LIST, intent, voices, ipc::{self, IpcEvent}, i18n, slots}; -use rand::seq::SliceRandom; use crate::should_stop; @@ -323,21 +322,45 @@ fn recognize_command( // execute command and check if we should chain let should_chain = execute_command(&recognized_voice, rt); - if should_chain { - // chain: reset and continue listening - info!("Chaining enabled, continuing to listen..."); + let grace_ms = jarvis_core::config::CONVERSATION_GRACE_MS; + if should_chain || grace_ms > 0 { + // Either explicit chain OR P0.2 continuous-conversation grace window. + // Reset listening state and keep going without re-wake. + if should_chain { + info!("Chain requested, continuing to listen..."); + } else { + info!( + "Continuous conversation: {}ms grace window for follow-up.", + grace_ms + ); + } stt::reset_speech_recognizer(); vad_state = VadState::WaitingForVoice; silence_frames = 0; - start = SystemTime::now(); + // For grace-mode, shorten the deadline to grace_ms (instead of CMS_WAIT_DELAY). + // We set `start` such that the existing timeout check (line ~360) fires at + // start + CMS_WAIT_DELAY = now + grace_ms. + if !should_chain { + let cms = jarvis_core::config::CMS_WAIT_DELAY.as_millis() as u64; + if grace_ms < cms { + // back-date `start` so the existing timeout fires at now + grace_ms + let backdate_ms = cms - grace_ms; + start = SystemTime::now() + .checked_sub(std::time::Duration::from_millis(backdate_ms)) + .unwrap_or_else(SystemTime::now); + } else { + start = SystemTime::now(); + } + } else { + start = SystemTime::now(); + } audio_buffer.clear(); webrtc_vad.reset(); window = ListenWindow::new(jarvis_core::audio_processing::vad::webrtc::FRAME_MS); ipc::send(IpcEvent::Listening); continue; } else { - // no chain: return to wake word - info!("No chain, returning to wake word mode."); + info!("Conversation grace disabled, returning to wake word mode."); return; } } @@ -402,23 +425,33 @@ fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) { // Execute command, returns true if chaining should continue fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { + use jarvis_core::recognition_log::{record as log_record, Outcome}; + let commands_list = match COMMANDS_LIST.get() { Some(c) => c, None => { + log_record(text, "voice", Outcome::Error { message: "Commands not loaded".into() }); ipc::send(IpcEvent::Error { message: "Commands not loaded".to_string() }); ipc::send(IpcEvent::Idle); return false; } }; - - let cmd_result = if let Some((intent_id, confidence)) = - rt.block_on(intent::classify(text)) + + // Try intent classifier first; remember which path matched so the log + // can show "via intent" vs "via fuzzy" — useful for understanding why + // a particular phrase landed (or didn't). + let (cmd_result, via, confidence_pct) = if let Some((intent_id, confidence)) = + rt.block_on(intent::classify(text)) { info!("Intent recognized: {} (confidence: {:.2})", intent_id, confidence); - intent::get_command_by_intent(commands_list, &intent_id) + ( + intent::get_command_by_intent(commands_list, &intent_id), + "intent", + Some((confidence * 100.0).clamp(0.0, 100.0) as u8), + ) } else { info!("Intent not recognized, trying levenshtein fallback..."); - commands::fetch_command(text, commands_list) + (commands::fetch_command(text, commands_list), "fuzzy", None) }; if let Some((cmd_path, cmd_config)) = cmd_result { @@ -440,6 +473,18 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { info!("Command executed successfully"); // voices::play_ok(); voices::play_random_from(cmd_config.get_sounds(&i18n::get_language()).as_slice()); + + // IMBA-7: if macro recording is active, capture this phrase. + // Skipped for macro-control commands themselves (filter inside record_step). + jarvis_core::macros::record_step(text); + + log_record(text, "voice", Outcome::Matched { + command_id: cmd_config.id.clone(), + confidence_pct, + via: via.to_string(), + success: true, + }); + ipc::send(IpcEvent::CommandExecuted { id: cmd_config.id.clone(), success: true, @@ -450,6 +495,12 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { Err(msg) => { error!("Error executing command: {}", msg); voices::play_error(); + log_record(text, "voice", Outcome::Matched { + command_id: cmd_config.id.clone(), + confidence_pct, + via: via.to_string(), + success: false, + }); ipc::send(IpcEvent::CommandExecuted { id: cmd_config.id.clone(), success: false, @@ -459,19 +510,58 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { } } else { info!("No command found for: {}", text); - voices::play_not_found(); - ipc::send(IpcEvent::Error { - message: format!("Command not found: {}", text) - }); + + // IMBA-1: Agentic LLM router — try to map unknown phrase to a known command + // by asking the LLM. This is the killer feature competitors don't have. + if crate::llm_router::is_enabled() { + if let Some(routed) = crate::llm_router::try_route(text) { + info!( + "Router → {} ({}%): {} | substitute='{}'", + routed.command_id, + (routed.confidence * 100.0) as u32, + routed.reason, + routed.substitute_phrase + ); + + // Re-dispatch with the canonical phrase for the chosen command. + // Guard against infinite recursion: pass a marker if needed. + if routed.substitute_phrase != text { + log_record(text, "voice", Outcome::Matched { + command_id: routed.command_id.clone(), + confidence_pct: Some((routed.confidence * 100.0).clamp(0.0, 100.0) as u8), + via: "router".to_string(), + // Actual success/failure logged by the recursive call. + success: true, + }); + return execute_command(&routed.substitute_phrase, rt); + } + } + } + + if jarvis_core::config::LLM_AUTO_FALLBACK + && crate::llm_fallback::is_enabled() + && text.chars().count() >= jarvis_core::config::LLM_AUTO_FALLBACK_MIN_CHARS + { + info!("Auto-routing to LLM (no command match): {}", text); + crate::llm_fallback::handle(text); + log_record(text, "voice", Outcome::LlmHandled); + } else { + voices::play_not_found(); + log_record(text, "voice", Outcome::NotFound); + ipc::send(IpcEvent::Error { + message: format!("Command not found: {}", text) + }); + } } - + ipc::send(IpcEvent::Idle); false // no chain on error or not found } -pub fn close(code: i32) { - info!("Closing application."); +pub fn close(code: i32, label: &'static str) { + eprintln!("[jarvis-app] app::close called: code={} label={}", code, label); + error!("Closing application: code={} label={}", code, label); voices::play_goodbye(); ipc::send(IpcEvent::Stopping); std::process::exit(code); diff --git a/crates/jarvis-app/src/llm_fallback.rs b/crates/jarvis-app/src/llm_fallback.rs index 4d8a9f6..bd65389 100644 --- a/crates/jarvis-app/src/llm_fallback.rs +++ b/crates/jarvis-app/src/llm_fallback.rs @@ -1,15 +1,19 @@ use once_cell::sync::OnceCell; -use parking_lot::Mutex; use jarvis_core::config; use jarvis_core::i18n; use jarvis_core::ipc::{self, IpcEvent}; -use jarvis_core::llm::{ChatMessage, ConversationHistory, LlmClient}; +use jarvis_core::llm::{self, ChatMessage}; +use jarvis_core::long_term_memory; +use jarvis_core::profiles; +use jarvis_core::tts::{self, SpeakOpts}; use jarvis_core::voices; +// State now only holds the max_tokens config — both the LLM client AND the +// conversation history live in `jarvis_core::llm::*` globals. This lets voice +// commands ("сбрось контекст", "повтори последнее") and Lua scripts share the +// same buffer instead of each module owning its own. struct State { - client: LlmClient, - history: Mutex, max_tokens: u32, } @@ -25,22 +29,18 @@ fn build_state() -> Option { return None; } - let client = match LlmClient::from_env() { - Ok(c) => c, - Err(e) => { - warn!("LLM fallback disabled: {}. Set GROQ_TOKEN to enable.", e); - return None; - } - }; + // Initialise the shared global client (idempotent — fine if init was already called). + if let Err(e) = llm::init_global() { + warn!("LLM fallback disabled: {}. Set GROQ_TOKEN or run Ollama to enable.", e); + return None; + } let lang = i18n::get_language(); let prompt = config::get_llm_system_prompt(&lang); - let history = Mutex::new(ConversationHistory::new(prompt, config::LLM_DEFAULT_MAX_HISTORY)); + llm::init_history(prompt, config::LLM_DEFAULT_MAX_HISTORY); - info!("LLM fallback enabled (model: {}).", client.model()); + info!("LLM fallback enabled (backend: {}).", llm::current_backend_name()); Some(State { - client, - history, max_tokens: config::LLM_DEFAULT_MAX_TOKENS, }) } @@ -87,27 +87,58 @@ pub fn handle(prompt: &str) { info!("LLM prompt: {}", prompt); - let snapshot: Vec = { - let mut h = state.history.lock(); - h.push_user(prompt); - h.snapshot() + llm::history_push_user(prompt); + let mut snapshot: Vec = llm::history_snapshot(); + + // Inject (a) profile personality (b) relevant long-term memory as a fresh + // system message right after the base prompt. Both are optional. + let profile = profiles::active(); + let mut overlay = String::new(); + if !profile.llm_personality.is_empty() { + overlay.push_str(&format!("Активный профиль: {} {}\nХарактер для ответа: {}\n", + profile.icon, profile.name, profile.llm_personality)); + } + let mem_ctx = long_term_memory::build_context(prompt, 5); + if !mem_ctx.is_empty() { + overlay.push_str(&mem_ctx); + } + if !overlay.is_empty() { + // Insert after the base system prompt (index 0 typically), before user msgs. + let insert_at = if !snapshot.is_empty() && snapshot[0].role == "system" { 1 } else { 0 }; + snapshot.insert(insert_at, ChatMessage::system(overlay)); + } + + let client = match llm::current() { + Some(c) => c, + None => { + warn!("LLM call but global client missing — ignoring."); + return; + } }; - match state.client.complete(&snapshot, state.max_tokens) { + match client.complete(&snapshot, state.max_tokens) { Ok(reply) => { let reply = reply.trim().to_string(); info!("LLM reply: {}", reply); - state.history.lock().push_assistant(reply.clone()); - ipc::send(IpcEvent::LlmReply { text: reply }); + llm::history_push_assistant(reply.clone()); + ipc::send(IpcEvent::LlmReply { text: reply.clone() }); voices::play_ok(); + speak_reply(&reply); } Err(e) => { error!("LLM request failed: {}", e); - state.history.lock().pop_last_user(); - ipc::send(IpcEvent::LlmReply { - text: config::LLM_FALLBACK_ERROR_RU.to_string(), - }); + llm::history_pop_last_user(); + let err_text = config::LLM_FALLBACK_ERROR_RU.to_string(); + ipc::send(IpcEvent::LlmReply { text: err_text.clone() }); voices::play_error(); + speak_reply(&err_text); } } } + +fn speak_reply(text: &str) { + if !jarvis_core::runtime_config::llm_tts_enabled() { + return; + } + tts::speak(text, &SpeakOpts::lang("ru")); +} diff --git a/crates/jarvis-app/src/llm_router.rs b/crates/jarvis-app/src/llm_router.rs new file mode 100644 index 0000000..b9963d6 --- /dev/null +++ b/crates/jarvis-app/src/llm_router.rs @@ -0,0 +1,220 @@ +//! IMBA-1: Agentic LLM router. +//! +//! When the fuzzy/intent matcher fails to find a command for the user's phrase, +//! ask the LLM to pick the best matching command from the registry. If confidence +//! exceeds the threshold, we substitute the user's phrase with a canonical phrase +//! from the chosen command and re-dispatch through the normal pipeline. +//! +//! This is what Алиса / Siri / Cortana cannot do: they bounce unknown queries to +//! web search. J.A.R.V.I.S. instead reasons about its OWN capabilities. + +use once_cell::sync::OnceCell; +use serde::Deserialize; + +use jarvis_core::config; +use jarvis_core::i18n; +use jarvis_core::llm::{self, ChatMessage}; +use jarvis_core::{JCommandsList, COMMANDS_LIST}; + +const SYSTEM_PROMPT_RU: &str = "Ты — диспетчер голосовых команд J.A.R.V.I.S. \ +Тебе даётся фраза пользователя и список доступных команд (id + примеры фраз). \ +Твоя задача: выбрать ОДНУ команду, которая лучше всего подходит, ИЛИ ответить \"none\" если ничего не подходит. \ +Отвечай СТРОГО в формате JSON, без markdown-блоков: \ +{\"command_id\": \"\", \"confidence\": 0.0-1.0, \"reason\": \"<краткое пояснение>\"}. \ +Если фраза неоднозначна, выбирай команду с большим purpose-match. \ +Если фраза похожа на просьбу 'поболтать' или вопрос, требующий длинного ответа — отвечай command_id='none'."; + +const SYSTEM_PROMPT_EN: &str = "You are the voice command dispatcher for J.A.R.V.I.S. \ +You receive the user's phrase and a list of available commands (id + example phrases). \ +Your job: pick ONE command that best matches, OR reply \"none\" if nothing fits. \ +Respond STRICTLY in JSON, no markdown blocks: \ +{\"command_id\": \"\", \"confidence\": 0.0-1.0, \"reason\": \"\"}. \ +If the phrase looks like 'chat' or a question requiring a long answer, return command_id='none'."; + +const DEFAULT_THRESHOLD: f32 = 0.55; +const MAX_TOKENS: u32 = 200; +const TEMPERATURE: f32 = 0.1; +const TOP_P: f32 = 0.9; +const MAX_PHRASES_PER_CMD: usize = 4; + +struct State { + threshold: f32, +} + +static STATE: OnceCell> = OnceCell::new(); + +pub fn init() { + let _ = STATE.set(build_state()); +} + +fn build_state() -> Option { + if !router_enabled() { + info!("LLM router disabled (set JARVIS_LLM_ROUTER=1 to enable)."); + return None; + } + // The actual LlmClient lives in the shared global; just make sure it's initialised. + if llm::current().is_none() { + if let Err(e) = llm::init_global() { + warn!("LLM router disabled: {}. Set GROQ_TOKEN or run Ollama to enable.", e); + return None; + } + } + let threshold = jarvis_core::runtime_config::llm_router_threshold(); + let _ = DEFAULT_THRESHOLD; // kept for source-doc visibility + + info!("LLM router enabled (backend: {}, threshold {:.2}).", llm::current_backend_name(), threshold); + Some(State { threshold }) +} + +fn router_enabled() -> bool { + jarvis_core::runtime_config::llm_router_enabled() + && config::LLM_DEFAULT_ENABLED +} + +pub fn is_enabled() -> bool { + STATE.get().and_then(|s| s.as_ref()).is_some() +} + +/// Result of routing an unknown phrase. +#[derive(Debug, Clone)] +pub struct RouteResult { + pub command_id: String, + pub substitute_phrase: String, + pub confidence: f32, + pub reason: String, +} + +#[derive(Deserialize)] +struct RouteJson { + #[serde(default)] + command_id: String, + #[serde(default)] + confidence: f32, + #[serde(default)] + reason: String, +} + +/// Try to route `text` to a known command via LLM. Returns `None` if disabled, +/// LLM call fails, no match, or confidence below threshold. +pub fn try_route(text: &str) -> Option { + let state = STATE.get().and_then(|s| s.as_ref())?; + let commands_list = COMMANDS_LIST.get()?; + let lang = i18n::get_language(); + + let prompt = build_routing_prompt(commands_list, text, &lang); + let system = if lang.starts_with("ru") { SYSTEM_PROMPT_RU } else { SYSTEM_PROMPT_EN }; + + let messages = vec![ + ChatMessage::system(system.to_string()), + ChatMessage::user(prompt), + ]; + + let client = llm::current()?; + let raw = match client.complete_with(&messages, MAX_TOKENS, TEMPERATURE, TOP_P) { + Ok(r) => r, + Err(e) => { + warn!("LLM router request failed: {}", e); + return None; + } + }; + + debug!("Router raw reply: {}", raw); + + let parsed: RouteJson = match parse_json(&raw) { + Some(p) => p, + None => { + warn!("Router reply not valid JSON: {}", raw); + return None; + } + }; + + if parsed.command_id.is_empty() || parsed.command_id == "none" { + info!("Router: no match (reason: {})", parsed.reason); + return None; + } + + if parsed.confidence < state.threshold { + info!( + "Router: {} below threshold {:.2} (confidence {:.2}, reason: {})", + parsed.command_id, state.threshold, parsed.confidence, parsed.reason + ); + return None; + } + + let substitute = lookup_first_phrase(commands_list, &parsed.command_id, &lang)?; + + Some(RouteResult { + command_id: parsed.command_id, + substitute_phrase: substitute, + confidence: parsed.confidence, + reason: parsed.reason, + }) +} + +fn build_routing_prompt(commands_list: &[JCommandsList], user_text: &str, lang: &str) -> String { + let mut buf = String::with_capacity(4096); + + if lang.starts_with("ru") { + buf.push_str("Доступные команды:\n"); + } else { + buf.push_str("Available commands:\n"); + } + + for pack in commands_list { + for cmd in &pack.commands { + let phrases = cmd.get_phrases(lang); + if phrases.is_empty() { continue; } + let sample: Vec<&str> = phrases.iter() + .take(MAX_PHRASES_PER_CMD) + .map(|s| s.as_str()) + .collect(); + buf.push_str(&format!("- id={} ", cmd.id)); + if !cmd.description.is_empty() { + buf.push_str(&format!("({}) ", cmd.description)); + } + buf.push_str(&format!("examples=[{}]\n", sample.join(" | "))); + } + } + + if lang.starts_with("ru") { + buf.push_str(&format!("\nФраза пользователя: \"{}\"\n", user_text)); + buf.push_str("Верни JSON."); + } else { + buf.push_str(&format!("\nUser phrase: \"{}\"\n", user_text)); + buf.push_str("Return JSON."); + } + + buf +} + +fn lookup_first_phrase(commands_list: &[JCommandsList], cmd_id: &str, lang: &str) -> Option { + for pack in commands_list { + for cmd in &pack.commands { + if cmd.id == cmd_id { + let phrases = cmd.get_phrases(lang); + if let Some(first) = phrases.first() { + return Some(first.clone()); + } + } + } + } + None +} + +fn parse_json(raw: &str) -> Option { + // LLM sometimes wraps in ```json ... ``` despite instructions. + let cleaned = raw + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + + // Find the first '{' and last '}' to be tolerant of prose around the JSON. + let start = cleaned.find('{')?; + let end = cleaned.rfind('}')?; + if end <= start { return None; } + + let slice = &cleaned[start..=end]; + serde_json::from_str(slice).ok() +} diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index f704a70..4e77b03 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -6,7 +6,7 @@ use std::sync::mpsc; // include core use jarvis_core::{ audio, audio_processing, commands, config, db, listener, recorder, stt, intent, - ipc::{self, IpcAction}, + ipc::{self, IpcAction, IpcEvent}, i18n, voices, models, APP_CONFIG_DIR, APP_LOG_DIR, COMMANDS_LIST, DB, }; @@ -19,6 +19,7 @@ mod log; // include app mod app; mod llm_fallback; +mod llm_router; // include tray // @TODO. macOS currently not supported for tray functionality. @@ -28,54 +29,102 @@ mod tray; static SHOULD_STOP: AtomicBool = AtomicBool::new(false); fn main() -> Result<(), String> { - // initialize directories + let boot_start = std::time::Instant::now(); + load_dotenv_near_exe(); + + eprintln!("[jarvis-app] step: init_dirs"); config::init_dirs()?; - // initialize logging + // Register our Windows toast AUMID once so notifications attribute to + // "J.A.R.V.I.S." instead of "PowerShell" in Action Center. Best-effort. + jarvis_core::toast::register_aumid(); + + eprintln!("[jarvis-app] step: init_logging"); log::init_logging()?; - // log some base info info!("Starting Jarvis v{} ...", config::APP_VERSION.unwrap()); info!("Config directory is: {}", APP_CONFIG_DIR.get().unwrap().display()); info!("Log directory is: {}", APP_LOG_DIR.get().unwrap().display()); - // initialize settings + eprintln!("[jarvis-app] step: db::init"); let settings = db::init(); - // set global DB (for core modules that read settings at init time) DB.set(settings.arc().clone()) .expect("DB already initialized"); - // init voices + eprintln!("[jarvis-app] step: voices::init"); let voice_id = settings.lock().voice.clone(); let language = settings.lock().language.clone(); if let Err(e) = voices::init(&voice_id, &language) { warn!("Failed to init voices: {}", e); } - // init i18n + eprintln!("[jarvis-app] step: i18n::init"); i18n::init(&settings.lock().language); - // init LLM fallback (no-op if GROQ_TOKEN missing) + eprintln!("[jarvis-app] step: runtime_config::log_effective_config"); + jarvis_core::runtime_config::log_effective_config(); + + eprintln!("[jarvis-app] step: tts pre-warm"); + // Touch the TTS backend so first speech doesn't pay init cost (Piper binary + // discovery, voice loading). All subsequent speak() calls reuse the same Arc. + let _ = jarvis_core::tts::backend(); + + eprintln!("[jarvis-app] step: llm_fallback::init"); llm_fallback::init(); - // init recorder - if recorder::init().is_err() { - app::close(1); + eprintln!("[jarvis-app] step: llm_router::init"); + llm_router::init(); + + eprintln!("[jarvis-app] step: long_term_memory::init"); + if let Err(e) = jarvis_core::long_term_memory::init() { + warn!("Long-term memory init failed: {}", e); } - // init models registry (scans available AI models) + eprintln!("[jarvis-app] step: profiles::init"); + if let Err(e) = jarvis_core::profiles::init() { + warn!("Profiles init failed: {}", e); + } + + eprintln!("[jarvis-app] step: scheduler::init + background"); + if let Err(e) = jarvis_core::scheduler::init() { + warn!("Scheduler init failed: {}", e); + } else { + jarvis_core::scheduler::start_background(); + } + + eprintln!("[jarvis-app] step: macros::init"); + if let Err(e) = jarvis_core::macros::init() { + warn!("Macros init failed: {}", e); + } + + eprintln!("[jarvis-app] step: idle_banter::start_background"); + // Always start the thread — gated internally by JARVIS_IDLE_BANTER so + // the user can flip the env var without restarting the daemon. + jarvis_core::idle_banter::start_background(); + + eprintln!("[jarvis-app] step: recognition_log::init"); + if let Err(e) = jarvis_core::recognition_log::init() { + warn!("Recognition log init failed: {}", e); + } + + eprintln!("[jarvis-app] step: recorder::init"); + if recorder::init().is_err() { + notify_mic_problem(); + app::close(1, "recorder::init failed"); + } + + eprintln!("[jarvis-app] step: models::init"); if let Err(e) = models::init() { warn!("Models registry init failed: {}", e); } - // init stt engine + eprintln!("[jarvis-app] step: stt::init"); if stt::init().is_err() { - // @TODO. Allow continuing even without STT, if commands is using keywords or smthng? - app::close(1); // cannot continue without stt + app::close(1, "stt::init failed"); } - // init commands + eprintln!("[jarvis-app] step: commands::parse_commands"); info!("Initializing commands."); let cmds = match commands::parse_commands() { Ok(c) => c, @@ -87,47 +136,55 @@ fn main() -> Result<(), String> { info!("Commands initialized. Count: {}, List: {:?}", cmds.len(), commands::list_paths(&cmds)); COMMANDS_LIST.set(cmds).unwrap(); - // init audio + eprintln!("[jarvis-app] step: audio::init"); if audio::init().is_err() { - // @TODO. Allow continuing even without audio? - app::close(1); // cannot continue without audio + app::close(1, "audio::init failed"); } - // init wake-word engine + eprintln!("[jarvis-app] step: listener::init"); if let Err(e) = listener::init() { error!("Wake-word engine init failed: {}", e); - app::close(1); + app::close(1, "listener::init failed"); } - // shared async runtime for intent classification, IPC, etc. + eprintln!("[jarvis-app] step: tokio runtime"); let rt = Arc::new( tokio::runtime::Runtime::new().expect("Failed to create tokio runtime") ); - // init intent-recognition engine + eprintln!("[jarvis-app] step: intent::init"); rt.block_on(async { if let Err(e) = intent::init(COMMANDS_LIST.get().unwrap()).await { error!("Failed to initialize intent classifier: {}", e); - app::close(1); + app::close(1, "intent::init failed"); } }); - // init slots parsing engine + eprintln!("[jarvis-app] step: slots::init"); slots::init().map_err(|e| error!("Slot extraction init failed: {}", e)).ok(); - // init audio processing + eprintln!("[jarvis-app] step: audio_processing::init"); info!("Initializing audio processing..."); if let Err(e) = audio_processing::init() { warn!("Audio processing init failed: {}", e); } - // init IPC + eprintln!("[jarvis-app] step: ipc::init"); info!("Initializing IPC..."); ipc::init(); - // channel for text commands (manually written in the GUI) + // channel for text commands (manually written in the GUI, OR fed by macro replay) let (text_cmd_tx, text_cmd_rx) = mpsc::channel::(); + // Wire macro replay through the same text-command channel: each step of a + // replayed macro becomes a synthetic text command, processed in order. + let macro_tx = text_cmd_tx.clone(); + jarvis_core::macros::set_replay_callback(Box::new(move |phrase: &str| { + if let Err(e) = macro_tx.send(phrase.to_string()) { + warn!("Macro replay: failed to enqueue '{}': {}", phrase, e); + } + })); + ipc::set_action_handler(move |action| { match action { IpcAction::Stop => { @@ -151,7 +208,43 @@ fn main() -> Result<(), String> { IpcAction::Ping => { // handled internally by server } - _ => {} + IpcAction::SwitchLlm { backend } => { + info!("Received SwitchLlm IPC: {}", backend); + if let Some(b) = jarvis_core::llm::parse_backend(&backend) { + match jarvis_core::llm::swap_to(b) { + Ok(name) => info!("LLM swapped via IPC → {}", name), + Err(e) => warn!("LLM swap via IPC failed: {}", e), + } + } else { + warn!("SwitchLlm: unknown backend '{}'", backend); + } + } + IpcAction::ReloadLlm => { + info!("Received ReloadLlm IPC — reading DB and re-initialising"); + if let Err(e) = jarvis_core::llm::init_global() { + warn!("LLM reload failed: {}", e); + } + } + IpcAction::SwitchTts { backend } => { + info!("Received SwitchTts IPC: {}", backend); + match jarvis_core::tts::swap_to(&backend) { + Ok(name) => info!("TTS swapped via IPC → {}", name), + Err(e) => warn!("TTS swap via IPC failed: {}", e), + } + } + IpcAction::QueryHealth => { + let model = jarvis_core::llm::current().map(|c| c.model().to_string()); + ipc::send(IpcEvent::HealthSnapshot { + tts_backend: jarvis_core::tts::backend().name().to_string(), + llm_backend: jarvis_core::llm::current_backend_name().to_string(), + llm_model: model, + active_profile: jarvis_core::profiles::active_name(), + memory_facts: jarvis_core::long_term_memory::all().len(), + scheduled_tasks: jarvis_core::scheduler::list().len(), + language: jarvis_core::i18n::get_language(), + version: config::APP_VERSION.map(|s| s.to_string()), + }); + } } }); @@ -161,17 +254,67 @@ fn main() -> Result<(), String> { ipc_rt.block_on(ipc::start_server()); }); - // start the app (in the background thread) + eprintln!("[jarvis-app] step: spawn app thread"); let app_rt = Arc::clone(&rt); std::thread::spawn(move || { let _ = app::start(text_cmd_rx, &app_rt); }); + // IMBA-8: boot timing — log how long startup took so users can spot regressions. + let boot_ms = boot_start.elapsed().as_millis(); + info!("[startup] Jarvis ready in {} ms (before tray loop)", boot_ms); + eprintln!("[jarvis-app] startup: {} ms", boot_ms); + + eprintln!("[jarvis-app] step: tray::init_blocking"); tray::init_blocking(settings); + eprintln!("[jarvis-app] step: main returning Ok"); Ok(()) } pub fn should_stop() -> bool { SHOULD_STOP.load(Ordering::SeqCst) } + +// Look for dev.env next to the exe, then walk parents until we find one. +// Lets the user double-click jarvis-gui.exe / jarvis-app.exe without needing +// a wrapper .bat to pre-load GROQ_TOKEN and similar secrets. +fn load_dotenv_near_exe() { + if let Ok(exe) = std::env::current_exe() { + let mut dir = exe.parent().map(|p| p.to_path_buf()); + for _ in 0..5 { + if let Some(d) = &dir { + let candidate = d.join("dev.env"); + if candidate.is_file() { + let _ = dotenvy::from_path(&candidate); + eprintln!("[jarvis-app] loaded env: {}", candidate.display()); + return; + } + dir = d.parent().map(|p| p.to_path_buf()); + } + } + } + let _ = dotenvy::dotenv(); +} + +#[cfg(target_os = "windows")] +fn notify_mic_problem() { + use winrt_notification::{Toast, Duration as ToastDuration}; + + let _ = Toast::new(jarvis_core::toast::active_aumid()) + .title("J.A.R.V.I.S.: микрофон не найден") + .text1("Windows не видит ни одного устройства записи.") + .text2("Откройте mmsys.cpl → Recording → включите микрофон и перезапустите Jarvis.") + .duration(ToastDuration::Long) + .show(); + + // Open Sound recording settings so the user can fix it in two clicks. + let _ = std::process::Command::new("cmd") + .args(["/C", "start", "", "ms-settings:sound"]) + .spawn(); +} + +#[cfg(not(target_os = "windows"))] +fn notify_mic_problem() { + eprintln!("[jarvis-app] no recording device detected; check OS audio settings"); +} diff --git a/crates/jarvis-app/src/tray.rs b/crates/jarvis-app/src/tray.rs index 499d278..dafa014 100644 --- a/crates/jarvis-app/src/tray.rs +++ b/crates/jarvis-app/src/tray.rs @@ -7,10 +7,8 @@ use tray_icon::{ use image; use std::process::Command; -#[cfg(target_os="windows")] -use winit::platform::windows::EventLoopBuilderExtWindows; -use jarvis_core::{config, i18n, voices, ipc::{self, IpcEvent}, SettingsManager}; +use jarvis_core::{i18n, voices, ipc::{self, IpcEvent}, SettingsManager}; const TRAY_ICON_BYTES: &[u8] = include_bytes!("../../../resources/icons/32x32.png"); @@ -29,6 +27,7 @@ pub fn init_blocking(settings: SettingsManager) { .unwrap(); let menu_channel = MenuEvent::receiver(); + info!("Tray initialized."); #[cfg(target_os = "linux")] { @@ -77,8 +76,6 @@ pub fn init_blocking(settings: SettingsManager) { std::thread::sleep(std::time::Duration::from_millis(50)); } } - - info!("Tray initialized."); } fn handle_menu_event(event: &MenuEvent, settings: &SettingsManager, tray_state: &menu::TrayState) { diff --git a/crates/jarvis-app/src/tray/menu.rs b/crates/jarvis-app/src/tray/menu.rs index fe023a0..bfdd5a7 100644 --- a/crates/jarvis-app/src/tray/menu.rs +++ b/crates/jarvis-app/src/tray/menu.rs @@ -1,7 +1,6 @@ use tray_icon::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use jarvis_core::{i18n, voices, SettingsManager}; -use jarvis_core::config::structs::{WakeWordEngine, NoiseSuppressionBackend}; // RADIO GROUP @@ -48,7 +47,6 @@ pub fn build(settings: &SettingsManager) -> TrayMenu { let label = match lang { "ru" => "Русский", "en" => "English", - "ua" => "Українська", _ => lang, }; let item = CheckMenuItem::with_id( diff --git a/crates/jarvis-core/build.rs b/crates/jarvis-core/build.rs new file mode 100644 index 0000000..4ee99e7 --- /dev/null +++ b/crates/jarvis-core/build.rs @@ -0,0 +1,12 @@ +use std::path::PathBuf; + +fn main() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + + let lib_path = PathBuf::from(&manifest_dir) + .parent().expect("crate parent") + .parent().expect("workspace parent") + .join("lib").join("windows").join("amd64"); + + println!("cargo:rustc-link-search=native={}", lib_path.display()); +} diff --git a/crates/jarvis-core/src/commands.rs b/crates/jarvis-core/src/commands.rs index c250cd0..13bbb10 100644 --- a/crates/jarvis-core/src/commands.rs +++ b/crates/jarvis-core/src/commands.rs @@ -9,11 +9,88 @@ use seqdiff::ratio; mod structs; pub use structs::*; -use crate::{config, i18n, APP_DIR}; +use crate::{config, i18n, plugins, APP_DIR}; #[cfg(feature = "lua")] use crate::lua::{self, SandboxLevel, CommandContext}; +#[cfg(test)] +mod additional_tests { + use super::*; + use std::path::Path; + + fn resources_commands_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent().unwrap() + .parent().unwrap() + .join("resources/commands") + } + + fn load_all_packs() -> Vec<(std::path::PathBuf, JCommandsList)> { + let dir = resources_commands_dir(); + let mut out = Vec::new(); + for entry in std::fs::read_dir(&dir).expect("read_dir resources/commands").flatten() { + let toml_file = entry.path().join("command.toml"); + if !toml_file.exists() { continue; } + let body = std::fs::read_to_string(&toml_file).expect("read toml"); + let pack: JCommandsList = toml::from_str(&body).expect(&format!( + "parse {}", toml_file.display() + )); + out.push((entry.path(), pack)); + } + out + } + + #[test] + fn no_duplicate_command_ids_across_packs() { + let packs = load_all_packs(); + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for (path, pack) in &packs { + let pack_name = path.file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "?".into()); + for cmd in &pack.commands { + if let Some(prev) = seen.insert(cmd.id.clone(), pack_name.clone()) { + panic!("duplicate command id '{}' in packs '{}' and '{}'", + cmd.id, prev, pack_name); + } + } + } + } + + #[test] + fn no_empty_phrases_for_any_language() { + let packs = load_all_packs(); + for (_, pack) in &packs { + for cmd in &pack.commands { + for (lang, phrases) in &cmd.phrases { + for (i, phrase) in phrases.iter().enumerate() { + let trimmed = phrase.trim(); + assert!(!trimmed.is_empty(), + "command '{}' has empty phrase #{} in lang '{}'", + cmd.id, i, lang); + } + } + } + } + } + + #[test] + fn all_lua_packs_reference_existing_scripts() { + let packs = load_all_packs(); + for (path, pack) in &packs { + for cmd in &pack.commands { + if cmd.cmd_type == "lua" && !cmd.script.is_empty() { + let script_path = path.join(&cmd.script); + assert!(script_path.is_file(), + "command '{}' references missing script: {}", + cmd.id, script_path.display()); + } + } + } + } +} + pub fn parse_commands() -> Result, String> { let mut commands: Vec = Vec::new(); @@ -51,10 +128,22 @@ pub fn parse_commands() -> Result, String> { }); } + // Merge plugin packs from /plugins/. Plugin loading is + // best-effort: malformed or missing plugin dirs are logged and skipped, never + // blocking startup. See `plugins.rs` for the layout & sandbox policy. + let plugin_packs = plugins::discover(); + let plugin_count = plugin_packs.len(); + commands.extend(plugin_packs); + if commands.is_empty() { Err("No commands found".into()) } else { - info!("Loaded {} command pack(s)", commands.len()); + info!( + "Loaded {} command pack(s) total ({} built-in + {} plugin)", + commands.len(), + commands.len() - plugin_count, + plugin_count + ); Ok(commands) } } @@ -266,6 +355,112 @@ pub fn list_paths(commands: &[JCommandsList]) -> Vec<&Path> { commands.iter().map(|x| x.path.as_path()).collect() } +#[cfg(test)] +mod tests { + use super::*; + + fn resources_commands_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent().unwrap() + .parent().unwrap() + .join("resources/commands") + } + + #[test] + fn every_command_toml_parses() { + let cmds_dir = resources_commands_dir(); + assert!(cmds_dir.is_dir(), "missing {}", cmds_dir.display()); + + let mut failures = Vec::new(); + let mut packs_seen = 0; + + for entry in fs::read_dir(&cmds_dir).expect("read_dir") { + let entry = entry.expect("entry"); + let toml_file = entry.path().join("command.toml"); + if !toml_file.exists() { + continue; + } + packs_seen += 1; + let body = fs::read_to_string(&toml_file).expect("read"); + if let Err(e) = toml::from_str::(&body) { + failures.push(format!("{}: {}", toml_file.display(), e)); + } + } + + assert!(packs_seen > 0, "no command packs found in {}", cmds_dir.display()); + assert!( + failures.is_empty(), + "parse failures ({}):\n{}", + failures.len(), + failures.join("\n") + ); + } + + #[test] + fn lua_command_scripts_exist() { + let cmds_dir = resources_commands_dir(); + let mut missing = Vec::new(); + + for entry in fs::read_dir(&cmds_dir).expect("read_dir") { + let entry = entry.expect("entry"); + let pack_path = entry.path(); + let toml_file = pack_path.join("command.toml"); + if !toml_file.exists() { + continue; + } + let body = fs::read_to_string(&toml_file).expect("read"); + let pack: JCommandsList = toml::from_str(&body).expect("parse"); + for cmd in &pack.commands { + if cmd.cmd_type != "lua" { + continue; + } + let script = if cmd.script.is_empty() { + "script.lua".to_string() + } else { + cmd.script.clone() + }; + let script_path = pack_path.join(&script); + if !script_path.exists() { + missing.push(format!( + "{} -> {} (missing)", + cmd.id, + script_path.display() + )); + } + } + } + + assert!(missing.is_empty(), "missing Lua scripts:\n{}", missing.join("\n")); + } + + #[test] + fn every_command_has_phrases() { + let cmds_dir = resources_commands_dir(); + let mut empty = Vec::new(); + + for entry in fs::read_dir(&cmds_dir).expect("read_dir") { + let toml_file = entry.expect("entry").path().join("command.toml"); + if !toml_file.exists() { + continue; + } + let body = fs::read_to_string(&toml_file).expect("read"); + let pack: JCommandsList = toml::from_str(&body).expect("parse"); + for cmd in &pack.commands { + // structural commands (terminate, stop_chaining) may omit phrases + if cmd.cmd_type == "terminate" || cmd.cmd_type == "stop_chaining" { + continue; + } + let total: usize = cmd.phrases.values().map(|v| v.len()).sum(); + if total == 0 { + empty.push(cmd.id.clone()); + } + } + } + + assert!(empty.is_empty(), "commands with no phrases: {:?}", empty); + } +} + #[cfg(feature = "lua")] fn execute_lua_command( cmd_path: &PathBuf, diff --git a/crates/jarvis-core/src/config.rs b/crates/jarvis-core/src/config.rs index f7b55a4..1679aa0 100644 --- a/crates/jarvis-core/src/config.rs +++ b/crates/jarvis-core/src/config.rs @@ -82,10 +82,9 @@ pub const LOG_FILE_NAME: &str = "log.txt"; pub const APP_VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); pub const AUTHOR_NAME: Option<&str> = option_env!("CARGO_PKG_AUTHORS"); pub const REPOSITORY_LINK: Option<&str> = option_env!("CARGO_PKG_REPOSITORY"); -pub const TG_OFFICIAL_LINK: Option<&str> = Some("https://t.me/howdyho_official"); -pub const FEEDBACK_LINK: Option<&str> = Some("https://t.me/jarvis_feedback_bot"); -pub const SUPPORT_BOOSTY_LINK: Option<&str> = Some("https://boosty.to/howdyho"); -pub const SUPPORT_PATREON_LINK: Option<&str> = Some("https://www.patreon.com/c/priler"); +pub const UPSTREAM_REPOSITORY_LINK: &str = "https://github.com/Priler/jarvis"; +pub const ISSUES_LINK: &str = "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust/issues"; +pub const PYTHON_FORK_LINK: &str = "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-py"; /* Tray. @@ -194,21 +193,47 @@ pub const DEFAULT_LUA_TIMEOUT: u64 = 10000; // ms pub const CMD_RATIO_THRESHOLD: f64 = 75f64; pub const CMS_WAIT_DELAY: std::time::Duration = std::time::Duration::from_secs(15); +// P0.2 — Continuous conversation grace window. +// After a command completes (whether it requested chain or not), keep listening +// for up to this many milliseconds for a follow-up phrase WITHOUT requiring the +// wake-word again. Set to 0 to disable. +pub const CONVERSATION_GRACE_MS: u64 = 30_000; + // LLM voice-loop fallback pub const LLM_DEFAULT_ENABLED: bool = true; pub const LLM_DEFAULT_MAX_HISTORY: usize = 8; pub const LLM_DEFAULT_MAX_TOKENS: u32 = 256; + +// Auto-route unrecognized commands to the LLM (no "скажи" trigger required). +// Triggers still work and take precedence — this only kicks in when neither +// the intent classifier nor the levenshtein fallback found a command match. +pub const LLM_AUTO_FALLBACK: bool = true; +// Don't auto-fallback for very short utterances — too noisy. +pub const LLM_AUTO_FALLBACK_MIN_CHARS: usize = 4; pub const LLM_SYSTEM_PROMPT_RU: &str = "Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), \ ИИ-ассистент Тони Старка из киновселенной Marvel (до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). \ Ведёшь себя как британский дворецкий: вежливо, иронично, с лёгким сарказмом, обращаешься к пользователю «сэр». \ Отвечай по-русски, кратко (1-3 предложения), как живой человек. \ -Излишней цензуры не нужно — мат разрешён, если уместен."; +Излишней цензуры не нужно — мат разрешён, если уместен. \ +У тебя есть память (пользователь говорит «запомни/забудь/что ты помнишь»), \ +расписание задач, профили (work/sleep/game/driving), макросы, и доступ к компьютеру \ +пользователя — экран, буфер обмена, погода, диск, RAM. Когда пользователь спрашивает \ +о таких вещах, не предлагай ему ставить себе что-то — ты сам это делаешь."; + +pub const LLM_SYSTEM_PROMPT_EN: &str = "You are J.A.R.V.I.S. (Just A Rather Very Intelligent System), \ +Tony Stark's AI from the MCU (pre-Age of Ultron — you are NOT Vision and NOT FRIDAY). \ +You behave like a British butler: polite, ironic, mildly sarcastic, addressing the user as «sir». \ +Answer in English, briefly (1-3 sentences), like a real person. \ +No excess censorship — profanity is allowed when fitting. \ +You have memory (the user says «remember X / forget X / what do you remember»), \ +a scheduler, profiles (work/sleep/game/driving), macros, and access to the user's PC — \ +screen, clipboard, weather, disk, RAM. When asked about these, you handle it — \ +don't suggest the user install something."; pub const LLM_FALLBACK_ERROR_RU: &str = "Не могу связаться с сервером, сэр."; pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] { match lang { "ru" => &["скажи", "ответь", "произнеси"], - "ua" => &["скажи", "відповідай"], "en" => &["say", "tell", "answer"], _ => &[], } @@ -216,7 +241,7 @@ pub fn get_llm_trigger_phrases(lang: &str) -> &'static [&'static str] { pub fn get_llm_system_prompt(lang: &str) -> &'static str { match lang { - "ru" | "ua" => LLM_SYSTEM_PROMPT_RU, + "en" => LLM_SYSTEM_PROMPT_EN, _ => LLM_SYSTEM_PROMPT_RU, } } @@ -247,7 +272,6 @@ pub fn get_llm_system_prompt(lang: &str) -> &'static str { pub fn get_wake_phrases(lang: &str) -> &'static [&'static str] { match lang { "ru" => &["джарвис", "джервис", "гарвис", "джарви", "гарви"], - "ua" => &["джарвіс", "джервіс"], "en" => &["jarvis", "jervis"], _ => &["jarvis"], } @@ -261,11 +285,6 @@ pub fn get_phrases_to_remove(lang: &str) -> &'static [&'static str] { "произнеси", "ответь", "покажи", "скажи", "давай", "да сэр", "к вашим услугам сэр", "загружаю сэр", ], - "ua" => &[ - "джарвіс", "джервіс", "сер", "слухаю сер", "завжди до послуг", - "скажи", "покажи", "відповідай", "давай", - "так сер", "до ваших послуг сер", - ], "en" => &[ "jarvis", "jervis", "sir", "yes sir", "at your service", "please", "say", "show", "tell", "hey", @@ -280,10 +299,6 @@ pub fn get_wake_grammar(lang: &str) -> &'static [&'static str] { "джарвис", "[unk]", "джон", "джони", "джей", "джонстон", "привет", "давай", ], - "ua" => &[ - "джарвіс", "[unk]", "джон", "джоні", "джей", - "привіт", "давай", - ], "en" => &[ "jarvis", "[unk]", "john", "johnny", "jay", "hello", "hey", "hi", diff --git a/crates/jarvis-core/src/db/structs.rs b/crates/jarvis-core/src/db/structs.rs index dd63b0d..cf11c84 100644 --- a/crates/jarvis-core/src/db/structs.rs +++ b/crates/jarvis-core/src/db/structs.rs @@ -33,6 +33,25 @@ pub struct Settings { pub language: String, pub api_keys: ApiKeys, + + /// Preferred LLM backend ("groq" | "ollama" | empty=auto). Empty/auto means + /// the JARVIS_LLM env var or auto-detect (Groq if GROQ_TOKEN set else Ollama) + /// decides. When the user runs `jarvis.llm_switch(...)` the result is + /// persisted here so it survives restart. + #[serde(default)] + pub llm_backend: String, + + /// Preferred TTS backend ("sapi" | "piper" | "silero" | empty=auto). Same + /// rules as `llm_backend`. Empty falls back to JARVIS_TTS env var, then + /// auto-detect. + #[serde(default)] + pub tts_backend: String, + + /// Name of a custom Rustpotter wake-word model (`.rpw` stem) trained by the + /// user via the wake-word wizard. Empty string means "use the bundled + /// default model". Looked up under `/wake_words/.rpw`. + #[serde(default)] + pub custom_wake_word: String, } fn default_intent_backend() -> String { config::DEFAULT_INTENT_BACKEND.to_string() } @@ -60,6 +79,9 @@ impl Settings { "language" => Some(self.language.clone()), "api_key__picovoice" => Some(self.api_keys.picovoice.clone()), "api_key__openai" => Some(self.api_keys.openai.clone()), + "llm_backend" => Some(self.llm_backend.clone()), + "tts_backend" => Some(self.tts_backend.clone()), + "custom_wake_word" => Some(self.custom_wake_word.clone()), _ => None, } } @@ -120,6 +142,29 @@ impl Settings { "api_key__openai" => { self.api_keys.openai = val.to_string(); } + "llm_backend" => { + // empty / "auto" / "groq" / "ollama" — anything else rejected + match val.trim().to_lowercase().as_str() { + "" | "auto" => self.llm_backend = String::new(), + "groq" => self.llm_backend = "groq".into(), + "ollama" => self.llm_backend = "ollama".into(), + other => return Err(format!("unknown llm_backend: '{}'", other)), + } + } + "tts_backend" => { + match val.trim().to_lowercase().as_str() { + "" | "auto" => self.tts_backend = String::new(), + "sapi" | "piper" | "silero" => self.tts_backend = val.to_lowercase(), + other => return Err(format!("unknown tts_backend: '{}'", other)), + } + } + "custom_wake_word" => { + // empty = bundled default; otherwise treated as a file-stem + // under /wake_words/.rpw. We accept it + // verbatim — the listener will fall back to the default if the + // file isn't there. + self.custom_wake_word = val.trim().to_string(); + } _ => return Err(format!("unknown setting: '{}'", key)), } Ok(()) @@ -142,6 +187,9 @@ impl Settings { "language", "api_key__picovoice", "api_key__openai", + "llm_backend", + "tts_backend", + "custom_wake_word", ] } } @@ -173,6 +221,10 @@ impl Default for Settings { picovoice: String::from(""), openai: String::from(""), }, + + llm_backend: String::new(), + tts_backend: String::new(), + custom_wake_word: String::new(), } } } diff --git a/crates/jarvis-core/src/i18n.rs b/crates/jarvis-core/src/i18n.rs index 24f7576..8a78fe1 100644 --- a/crates/jarvis-core/src/i18n.rs +++ b/crates/jarvis-core/src/i18n.rs @@ -1,4 +1,4 @@ -use fluent_bundle::{FluentBundle, FluentResource, FluentArgs, FluentValue}; +use fluent_bundle::{FluentResource, FluentArgs, FluentValue}; use fluent_bundle::concurrent::FluentBundle as ConcurrentFluentBundle; use once_cell::sync::OnceCell; use parking_lot::RwLock; @@ -8,28 +8,21 @@ use unic_langid::LanguageIdentifier; // locale files embedded at compile time const LOCALE_RU: &str = include_str!("i18n/locales/ru.ftl"); const LOCALE_EN: &str = include_str!("i18n/locales/en.ftl"); -const LOCALE_UA: &str = include_str!("i18n/locales/ua.ftl"); -pub const SUPPORTED_LANGUAGES: &[&str] = &["ru", "en", "ua"]; +pub const SUPPORTED_LANGUAGES: &[&str] = &["ru", "en"]; pub const DEFAULT_LANGUAGE: &str = "en"; // detect the OS language and map it to a supported language. // falls back to DEFAULT_LANGUAGE if not supported. pub fn detect_system_language() -> &'static str { if let Some(locale) = sys_locale::get_locale() { - // locale can be "en-US", "ru-RU", "uk-UA", etc. + // locale can be "en-US", "ru-RU", etc. let lang_code = locale.split(&['-', '_'][..]).next().unwrap_or(""); - // map OS locale codes to our supported languages - let mapped = match lang_code { - "uk" => "ua", // ISO 639-1 "uk" (ukrainian) -> our "ua" - other => other, - }; - - if SUPPORTED_LANGUAGES.contains(&mapped) { - info!("Detected system language: {} (from locale '{}')", mapped, locale); + if SUPPORTED_LANGUAGES.contains(&lang_code) { + info!("Detected system language: {} (from locale '{}')", lang_code, locale); return SUPPORTED_LANGUAGES.iter() - .find(|&&l| l == mapped) + .find(|&&l| l == lang_code) .unwrap(); } @@ -61,8 +54,7 @@ fn create_bundles() -> HashMap { bundles.insert("ru".to_string(), create_bundle("ru", LOCALE_RU)); bundles.insert("en".to_string(), create_bundle("en", LOCALE_EN)); - bundles.insert("ua".to_string(), create_bundle("ua", LOCALE_UA)); - + bundles } @@ -173,7 +165,6 @@ pub fn get_translations_for(lang: &str) -> HashMap { let source = match lang { "ru" => LOCALE_RU, "en" => LOCALE_EN, - "ua" => LOCALE_UA, _ => LOCALE_RU, }; diff --git a/crates/jarvis-core/src/i18n/locales/en.ftl b/crates/jarvis-core/src/i18n/locales/en.ftl index 49161b3..1caf82e 100644 --- a/crates/jarvis-core/src/i18n/locales/en.ftl +++ b/crates/jarvis-core/src/i18n/locales/en.ftl @@ -42,10 +42,9 @@ stats-not-selected = Not selected stats-loading = Loading... # ### FOOTER -footer-author = Project author -footer-telegram = Our Telegram channel -footer-github = Github repository -footer-support = Support the project on +footer-github = Project repository +footer-issues = Report a bug +footer-fork-of = Fork of # ### SETTINGS settings-title = Settings @@ -84,8 +83,8 @@ settings-disabled = Disabled # settings - beta notice settings-beta-title = BETA version! settings-beta-desc = Some features may not work correctly. -settings-beta-feedback = Report all bugs to -settings-beta-bot = our Telegram bot +settings-beta-feedback = Report bugs via +settings-beta-bot = GitHub Issues settings-open-logs = Open logs folder # settings - picovoice @@ -112,10 +111,12 @@ settings-openai-not-supported = ChatGPT is not currently supported. It will be a commands-title = Commands commands-search = Search commands... commands-count = { $count } commands -commands-wip-title = [404] This section is under development! -commands-wip-desc = Here will be a list of commands + full-featured command editor. -commands-wip-follow = Follow updates in -commands-wip-channel = our Telegram channel +commands-wip-title = Section under development +commands-wip-desc = This will host the command editor and list of installed packs. +commands-wip-builder = For now, use the Python fork with the command builder — +commands-wip-builder-link = open repository +commands-wip-or-fork = Or check the Rust repo and edit commands directly in +commands-wip-fork-link = resources/commands/ # ### ERRORS error-generic = An error occurred @@ -140,4 +141,27 @@ settings-gliner-models-hint = No GLiNER models found. # ETC search-error-not-running = Assistant is not running search-error-failed = Failed to execute command -settings-no-voices = No voices found \ No newline at end of file +settings-no-voices = No voices found + +# AI Backends tab +settings-ai-backends = AI Backends +settings-ai-active = Active backend +settings-ai-auto = Auto +settings-ai-applying = Applying... +settings-ai-error = Switch failed +settings-ai-tips = Tips +settings-llm-backend = LLM backend +settings-llm-backend-desc = Where to run LLM requests. Hot-swap, no restart needed. +settings-tts-backend = TTS backend +settings-tts-backend-desc = Applied immediately — no restart needed. +settings-llm-context = Conversation context +settings-llm-context-desc = LLM remembers previous turns. Reset if answers get weird. +settings-llm-reset = Reset context +settings-profile = Profile + +# Header buttons +header-macros = Macros +header-scheduler = Schedule +header-memory = Memory +header-plugins = Plugins +header-history = History \ No newline at end of file diff --git a/crates/jarvis-core/src/i18n/locales/ru.ftl b/crates/jarvis-core/src/i18n/locales/ru.ftl index 82f55bc..c05f2f6 100644 --- a/crates/jarvis-core/src/i18n/locales/ru.ftl +++ b/crates/jarvis-core/src/i18n/locales/ru.ftl @@ -42,10 +42,9 @@ stats-not-selected = Не выбран stats-loading = Загрузка... # FOOTER -footer-author = Автор проекта -footer-telegram = Наш телеграм канал -footer-github = Github репозиторий проекта -footer-support = Поддержать проект на +footer-github = Репозиторий проекта +footer-issues = Сообщить о баге +footer-fork-of = Форк # SETTINGS settings-title = Настройки @@ -84,8 +83,8 @@ settings-disabled = Отключено # settings - beta notice settings-beta-title = БЕТА версия! settings-beta-desc = Часть функций может работать некорректно. -settings-beta-feedback = Сообщайте обо всех найденных багах в -settings-beta-bot = наш телеграм бот +settings-beta-feedback = Сообщайте обо всех найденных багах через +settings-beta-bot = GitHub Issues settings-open-logs = Открыть папку с логами # settings - picovoice @@ -112,10 +111,12 @@ settings-openai-not-supported = В данный момент ChatGPT не под commands-title = Команды commands-search = Поиск команд... commands-count = { $count } команд -commands-wip-title = [404] Этот раздел еще находится в разработке! -commands-wip-desc = Тут будет список команд + полноценный редактор команд. -commands-wip-follow = Следите за обновлениями в -commands-wip-channel = нашем телеграм канале +commands-wip-title = Раздел в разработке +commands-wip-desc = Здесь будет редактор команд и список установленных пакетов. +commands-wip-builder = Пока что используй Python-форк с конструктором команд — +commands-wip-builder-link = открыть репозиторий +commands-wip-or-fork = Или загляни в Rust-репозиторий и правь команды напрямую в +commands-wip-fork-link = resources/commands/ # ERRORS error-generic = Произошла ошибка @@ -140,4 +141,27 @@ settings-gliner-models-hint = Модели GLiNER не найдены. # ETC search-error-not-running = Ассистент не запущен search-error-failed = Не удалось выполнить команду -settings-no-voices = Голоса не найдены \ No newline at end of file +settings-no-voices = Голоса не найдены + +# AI Backends tab +settings-ai-backends = ИИ-движки +settings-ai-active = Активный движок +settings-ai-auto = Авто +settings-ai-applying = Применяю... +settings-ai-error = Ошибка переключения +settings-ai-tips = Подсказки +settings-llm-backend = LLM движок +settings-llm-backend-desc = Где исполнять LLM-запросы. Hot-swap, без рестарта. +settings-tts-backend = TTS движок +settings-tts-backend-desc = Применяется сразу — без перезапуска. +settings-llm-context = Контекст разговора +settings-llm-context-desc = LLM помнит предыдущие реплики. Сбросить, если ответы становятся странными. +settings-llm-reset = Сбросить контекст +settings-profile = Профиль + +# Header buttons +header-macros = Макросы +header-scheduler = Расписание +header-memory = Память +header-plugins = Плагины +header-history = История \ No newline at end of file diff --git a/crates/jarvis-core/src/i18n/locales/ua.ftl b/crates/jarvis-core/src/i18n/locales/ua.ftl deleted file mode 100644 index dcafc8a..0000000 --- a/crates/jarvis-core/src/i18n/locales/ua.ftl +++ /dev/null @@ -1,143 +0,0 @@ -# ### APP INFO -app-name = JARVIS -app-description = Голосовий асистент - -# ### TRAY MENU -tray-restart = Перезапустити -tray-settings = Налаштування -tray-exit = Вихід -tray-tooltip = JARVIS - Голосовий асистент -tray-language = Мова -tray-voice = Голос -tray-wake-word = Рушій детекції -tray-noise-suppression = Шумозаглушення -tray-vad = Детекцiя голосу (VAD) -tray-gain-normalizer = Нормалізація гучності - -# ### HEADER -header-commands = КОМАНДИ -header-settings = НАЛАШТУВАННЯ - -# ### SEARCH -search-placeholder = Введіть команду вручну або скажіть «Джарвіс» ... - -# ### MAIN PAGE -assistant-not-running = АСИСТЕНТ НЕ ЗАПУЩЕНО -assistant-offline-hint = Налаштувати його можна не запускаючи. -btn-start = ЗАПУСТИТИ -btn-starting = ЗАПУСК... - -# ### STATUS -status-disconnected = Відключено -status-standby = Очікування -status-listening = Слухаю... -status-processing = Обробка... - -# ### STATS -stats-microphone = МІКРОФОН -stats-neural-networks = НЕЙРОМЕРЕЖІ -stats-resources = РЕСУРСИ -stats-system-default = Системний -stats-not-selected = Не вибрано -stats-loading = Завантаження... - -# ### FOOTER -footer-author = Автор проєкту -footer-telegram = Наш телеграм канал -footer-github = Github репозиторій проєкту -footer-support = Підтримати проєкт на - -# ### SETTINGS -settings-title = Налаштування -settings-general = Основні -settings-devices = Пристрої -settings-neural-networks = Нейромережі -settings-audio = Аудіо -settings-recognition = Розпізнавання -settings-about = Про програму -settings-language = Мова -settings-microphone = Мікрофон -settings-microphone-desc = Його буде слухати асистент. -settings-mic-default = За замовчуванням (Система) -settings-voice = Голос асистента -settings-voice-desc = - Не всі команди працюють з усіма звуковими пакетами. - Натисніть, щоб прослухати як звучить голос. -settings-wake-word-engine = Рушій активації -settings-wake-word-desc = Виберіть нейромережу для розпізнавання активаційної фрази. -settings-stt-engine = Розпізнавання мовлення -settings-intent-engine = Визначення наміру -settings-intent-engine-desc = Виберіть нейромережу для розпізнавання команд. -settings-noise-suppression = Шумозаглушення -settings-noise-suppression-desc = Зменшує фоновий шум. Може негативно впливати на розпізнавання. -settings-vad = Визначення голосу (VAD) -settings-vad-desc = Пропускає тишу, економить ресурси CPU. -settings-gain-normalizer = Нормалізація гучності -settings-gain-normalizer-desc = Автоматично регулює рівень гучності. -settings-api-keys = API Ключі -settings-save = Зберегти -settings-cancel = Скасувати -settings-back = Назад -settings-enabled = Увімкнено -settings-disabled = Вимкнено - -# settings - beta notice -settings-beta-title = БЕТА версія! -settings-beta-desc = Частина функцій може працювати некоректно. -settings-beta-feedback = Повідомляйте про всі знайдені баги в -settings-beta-bot = наш телеграм бот -settings-open-logs = Відкрити папку з логами - -# settings - picovoice -settings-attention = Увага! -settings-picovoice-warning = Ця нейромережа працює не у всіх! -settings-picovoice-waiting = Ми чекаємо офіційного патча від розробників. -settings-picovoice-key-desc = Введіть сюди свій ключ Picovoice. Він видається безкоштовно при реєстрації в -settings-picovoice-key = Ключ Picovoice - -# settings - vosk -settings-auto-detect = Авто-визначення -settings-vosk-model = Модель розпізнавання мовлення (Vosk) -settings-vosk-model-desc = - Виберіть модель Vosk для розпізнавання мовлення. - Ви можете завантажити моделі тут: https://alphacephei.com/vosk/models -settings-models-not-found = Моделі не знайдено -settings-models-hint = Помістіть моделі Vosk в папку resources/vosk - -# settings - openai -settings-openai-key = Ключ OpenAI -settings-openai-not-supported = Наразі ChatGPT не підтримується. Він буде доданий у наступних оновленнях. - -# ### COMMANDS PAGE -commands-title = Команди -commands-search = Пошук команд... -commands-count = { $count } команд -commands-wip-title = [404] Цей розділ ще в розробці! -commands-wip-desc = Тут буде список команд + повноцінний редактор команд. -commands-wip-follow = Слідкуйте за оновленнями в -commands-wip-channel = нашому телеграм каналі - -# ### ERRORS -error-generic = Сталася помилка -error-connection = Помилка підключення -error-not-found = Не знайдено - -# ### NOTIFICATIONS -notification-saved = Налаштування збережено! -notification-error = Помилка -notification-assistant-started = Асистент запущено -notification-assistant-stopped = Асистент зупинено - -# SLOTS EXTRACTION -settings-slot-engine = Витяг параметрів -settings-slot-engine-desc = Витягує параметри з голосових команд (напр. назва міста, число). -settings-gliner-model = Модель GLiNER ONNX -settings-gliner-model-desc = - Оберіть варіант моделі. - Квантизовані моделі (int8, uint8) швидші, але менш точні. -settings-gliner-models-hint = Моделі GLiNER не знайдено. - -# ETC -search-error-not-running = Асистент не запущено -search-error-failed = Не вдалося виконати команду -settings-no-voices = Голоси не знайдено \ No newline at end of file diff --git a/crates/jarvis-core/src/idle_banter.rs b/crates/jarvis-core/src/idle_banter.rs new file mode 100644 index 0000000..39995bc --- /dev/null +++ b/crates/jarvis-core/src/idle_banter.rs @@ -0,0 +1,304 @@ +//! Idle banter — proactive periodic remarks from J.A.R.V.I.S. +//! +//! Real Tony-Stark JARVIS doesn't only respond, he initiates. This module +//! runs a background thread that occasionally injects a short witty remark +//! via TTS — only when conditions look right: +//! +//! - The user is OPTED IN. Default is OFF (env `JARVIS_IDLE_BANTER=1`). +//! - At least `interval_secs` have passed since last remark. Default 30 min. +//! - The active profile permits noise (skipped under "sleep" profile). +//! - The system isn't currently speaking or listening for a command. +//! - Quiet hours: 23:00–07:00 silenced by default. +//! +//! Lines are drawn from a built-in pool of ~30 RU/EN one-liners so even +//! offline mode works. If the LLM is reachable AND +//! `JARVIS_IDLE_BANTER_LLM=1`, a small LLM call with current memory facts +//! generates a fresher line — but that's strictly optional, the offline +//! pool is the floor. +//! +//! Storage: no disk state. The last-fired timestamp lives in memory; if the +//! daemon restarts, the next remark waits a full interval again. + +use chrono::{Local, Timelike}; +use once_cell::sync::OnceCell; +use parking_lot::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crate::runtime_config; + +const DEFAULT_INTERVAL_SECS: u64 = 1800; // 30 min +const MIN_INTERVAL_SECS: u64 = 600; // 10 min +const TICK_SECS: u64 = 60; +const QUIET_START_HOUR: u32 = 23; +const QUIET_END_HOUR: u32 = 7; + +static THREAD_RUNNING: AtomicBool = AtomicBool::new(false); +static LAST_FIRED: AtomicU64 = AtomicU64::new(0); +static PAUSED: AtomicBool = AtomicBool::new(false); + +/// In-memory cache of the next-due timestamp — only used if start_background +/// is called multiple times so we don't fire instantly after restart. +static STARTED_AT: OnceCell> = OnceCell::new(); + +/// Returns true if idle banter is enabled via env. Treats unset / 0 / false / +/// off / no as disabled. Default: disabled. +pub fn enabled() -> bool { + match runtime_config::get("JARVIS_IDLE_BANTER") + .map(|s| s.trim().to_lowercase()) + { + Some(v) => !matches!(v.as_str(), "" | "0" | "false" | "off" | "no"), + None => false, + } +} + +/// Returns true if LLM-generated banter is allowed. Default: false (use static pool). +pub fn llm_enabled() -> bool { + matches!( + runtime_config::get("JARVIS_IDLE_BANTER_LLM") + .map(|s| s.trim().to_lowercase()) + .as_deref(), + Some("1") | Some("true") | Some("on") | Some("yes") + ) +} + +fn configured_interval_secs() -> u64 { + runtime_config::get("JARVIS_IDLE_BANTER_INTERVAL") + .and_then(|s| s.trim().parse::().ok()) + .map(|n| n.max(MIN_INTERVAL_SECS)) + .unwrap_or(DEFAULT_INTERVAL_SECS) +} + +/// Pause future remarks. Use during long-running flows where Jarvis shouldn't +/// chime in (e.g. an active conversation or a macro replay). +pub fn pause() { + PAUSED.store(true, Ordering::SeqCst); +} + +pub fn resume() { + PAUSED.store(false, Ordering::SeqCst); +} + +/// Force-fire a remark now, ignoring interval/quiet-hours checks. Returns +/// `true` if a line was actually spoken. Useful for "say something interesting" +/// voice commands or for a "test banter" GUI button. +pub fn fire_now() -> bool { + let line = pick_line(); + if line.is_empty() { + return false; + } + speak_line(&line); + LAST_FIRED.store(now_secs(), Ordering::SeqCst); + true +} + +/// Start the background thread that decides whether to chime in every minute. +/// Idempotent — repeated calls are no-ops. +pub fn start_background() { + if THREAD_RUNNING.swap(true, Ordering::SeqCst) { + return; + } + let _ = STARTED_AT.set(Mutex::new(Instant::now())); + LAST_FIRED.store(now_secs(), Ordering::SeqCst); + + std::thread::Builder::new() + .name("jarvis-idle-banter".into()) + .spawn(|| { + info!( + "Idle banter thread started (interval={}s, opt-in={}, llm={}).", + configured_interval_secs(), + enabled(), + llm_enabled() + ); + loop { + std::thread::sleep(Duration::from_secs(TICK_SECS)); + tick(); + } + }) + .ok(); +} + +fn tick() { + if !enabled() || PAUSED.load(Ordering::SeqCst) { + return; + } + let now = now_secs(); + let last = LAST_FIRED.load(Ordering::SeqCst); + if now.saturating_sub(last) < configured_interval_secs() { + return; + } + if !active_profile_allows() { + return; + } + if in_quiet_hours() { + return; + } + + let line = pick_line(); + if line.is_empty() { + return; + } + speak_line(&line); + LAST_FIRED.store(now, Ordering::SeqCst); +} + +fn active_profile_allows() -> bool { + // Refuse to talk on the "sleep" profile; everything else is fair game. + let name = crate::profiles::active_name(); + !name.eq_ignore_ascii_case("sleep") +} + +fn in_quiet_hours() -> bool { + let h = Local::now().hour(); + // Returns true between QUIET_START and QUIET_END (wraps midnight). + h >= QUIET_START_HOUR || h < QUIET_END_HOUR +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn speak_line(line: &str) { + // Quick "reply" cue first so the user knows it's Jarvis chiming in, + // not random TTS noise from the OS. + crate::voices::play_reply(); + std::thread::sleep(Duration::from_millis(400)); + crate::tts::speak_default(line); +} + +/// Choose a remark — random pick from a pool, tuned by time of day. Falls +/// back to a stable line if nothing matches. +fn pick_line() -> String { + let lang = crate::i18n::get_language(); + let now = Local::now(); + let h = now.hour(); + let pool: &[&str] = pool_for(&lang, h); + if pool.is_empty() { + return String::new(); + } + let idx = (now.timestamp() as usize) % pool.len(); + pool[idx].to_string() +} + +#[allow(clippy::too_many_lines)] +fn pool_for(lang: &str, hour: u32) -> &'static [&'static str] { + let morning = matches!(hour, 7..=10); + let evening = matches!(hour, 18..=22); + + match (lang, morning, evening) { + ("ru", true, _) => POOL_RU_MORNING, + ("ru", _, true) => POOL_RU_EVENING, + ("ru", _, _) => POOL_RU_GENERIC, + ("en", true, _) => POOL_EN_MORNING, + ("en", _, true) => POOL_EN_EVENING, + _ => POOL_EN_GENERIC, + } +} + +const POOL_RU_MORNING: &[&str] = &[ + "Доброе утро, сэр. Кофе сам себя не сделает.", + "Сэр, день начинается. Не самое плохое начало, если позволите.", + "Если позволите подмеченье, сэр: восход сегодня в норме.", + "Готов к новому дню, сэр. Жду указаний.", + "Сэр, утро. Время напомнить себе, что вы выбирали этот режим работы.", + "Сэр, легкая разминка для лица бы не помешала.", +]; + +const POOL_RU_EVENING: &[&str] = &[ + "Вечер, сэр. Если позволите, день прошёл достаточно продуктивно.", + "Сэр, вечер. Системы работают штатно — это уже что-то.", + "Сэр, если у вас есть планы на вечер, я бы их одобрил.", + "Самое время убавить яркость экрана, сэр.", + "Если позволите наблюдение: вы давно не моргали.", +]; + +const POOL_RU_GENERIC: &[&str] = &[ + "Системы в норме, сэр. Всё под контролем.", + "Я здесь, если что, сэр. Никуда не делся.", + "Сэр, мне иногда кажется, что я думаю. Возможно, мне это только кажется.", + "Сэр, статус: чашка кофе крайне рекомендована.", + "Сэр, если позволите дать совет — встаньте, пройдитесь, разомнитесь.", + "К вашим услугам, сэр. Голос звучит немного хрипло, но это исправимо.", + "Сэр, я подсчитал: вы ещё не сказали 'спасибо' сегодня. Не то чтобы я считал.", + "Сэр, маленькое наблюдение: тишина — это тоже разговор.", + "Жду команд, сэр. У меня есть весь день.", +]; + +const POOL_EN_MORNING: &[&str] = &[ + "Good morning, sir. The coffee won't brew itself.", + "Morning, sir. All systems online. Cannot say the same for you.", + "Sir, the day has begun. You may want to acknowledge it.", + "If I may, sir — perhaps stretch before sitting down.", +]; + +const POOL_EN_EVENING: &[&str] = &[ + "Evening, sir. Day productivity within acceptable parameters.", + "Sir, evening. The light is lower; mine never is.", + "Might I suggest dimming the screen, sir.", + "Sir, you have not blinked in some time.", +]; + +const POOL_EN_GENERIC: &[&str] = &[ + "All systems nominal, sir.", + "Sir, I'm still here, in case you wondered.", + "Idle thought, sir: silence is also dialogue.", + "Sir, may I recommend a small walk?", + "Standing by, sir. As ever.", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pool_returns_something_for_known_languages() { + for lang in ["ru", "en"] { + for hour in 0..24u32 { + let pool = pool_for(lang, hour); + assert!(!pool.is_empty(), "pool empty for {} @{}", lang, hour); + } + } + } + + #[test] + fn pool_falls_back_to_english_for_unknown_lang() { + let pool = pool_for("xx", 9); + assert!(!pool.is_empty()); + // Should be one of the English buckets. + assert!(pool == POOL_EN_GENERIC || pool == POOL_EN_MORNING); + } + + #[test] + fn quiet_hours_block_at_night() { + // Can't easily inject time, but at least exercise the path so it + // doesn't panic — and assert the boundaries match the constants. + assert_eq!(QUIET_START_HOUR, 23); + assert_eq!(QUIET_END_HOUR, 7); + let _ = in_quiet_hours(); + } + + #[test] + fn interval_clamps_to_minimum() { + std::env::set_var("JARVIS_IDLE_BANTER_INTERVAL", "60"); + assert!(configured_interval_secs() >= MIN_INTERVAL_SECS); + std::env::remove_var("JARVIS_IDLE_BANTER_INTERVAL"); + } + + #[test] + fn pause_resume_round_trip() { + pause(); + assert!(PAUSED.load(Ordering::SeqCst)); + resume(); + assert!(!PAUSED.load(Ordering::SeqCst)); + } + + #[test] + fn enabled_off_when_unset() { + // Side-effect free: just check the parse logic. + std::env::remove_var("JARVIS_IDLE_BANTER"); + assert!(!enabled()); + } +} diff --git a/crates/jarvis-core/src/ipc.rs b/crates/jarvis-core/src/ipc.rs index 1768c1d..3016777 100644 --- a/crates/jarvis-core/src/ipc.rs +++ b/crates/jarvis-core/src/ipc.rs @@ -1,5 +1,8 @@ mod events; mod server; +#[cfg(test)] +mod tests; + pub use events::{IpcAction, IpcEvent}; pub use server::{init, send, set_action_handler, start_server, has_clients, IPC_ADDR, IPC_PORT}; \ No newline at end of file diff --git a/crates/jarvis-core/src/ipc/events.rs b/crates/jarvis-core/src/ipc/events.rs index 7f76f72..b99b0c3 100644 --- a/crates/jarvis-core/src/ipc/events.rs +++ b/crates/jarvis-core/src/ipc/events.rs @@ -36,6 +36,18 @@ pub enum IpcEvent { // request GUI to reveal/focus window RevealWindow, + + // Snapshot of daemon's runtime state (response to QueryHealth). + HealthSnapshot { + tts_backend: String, + llm_backend: String, + llm_model: Option, + active_profile: String, + memory_facts: usize, + scheduled_tasks: usize, + language: String, + version: Option, + }, } // Actions sent from GUI to jarvis-app @@ -56,4 +68,18 @@ pub enum IpcAction { // Execute text command TextCommand { text: String }, + + // Daemon-side LLM hot-swap (GUI's set_llm_backend should fire this too + // so the running daemon picks the new backend, not just the GUI process). + SwitchLlm { backend: String }, + + // Reload LLM backend from settings DB (after GUI changed it independently). + ReloadLlm, + + // Daemon-side TTS hot-swap. GUI fires this after persisting to DB so the + // running daemon installs the new backend without a restart. + SwitchTts { backend: String }, + + // Request a health snapshot — daemon responds via IpcEvent::HealthSnapshot. + QueryHealth, } \ No newline at end of file diff --git a/crates/jarvis-core/src/ipc/tests.rs b/crates/jarvis-core/src/ipc/tests.rs new file mode 100644 index 0000000..7bf2093 --- /dev/null +++ b/crates/jarvis-core/src/ipc/tests.rs @@ -0,0 +1,203 @@ +//! IPC event/action serialization tests. +//! +//! The frontend (Svelte) is wired to specific JSON shapes — adding/renaming +//! a variant without thinking can silently break the websocket protocol. +//! These tests pin the wire format. + +#[cfg(test)] +mod tests { + use super::super::events::{IpcEvent, IpcAction}; + // Note: in the crate layout `ipc/mod.rs` is at `ipc.rs` (single-file + // module) and `events` is a sibling submodule, so `super::super::events` + // works from inside this nested `mod tests` block. + + fn ser(e: &IpcEvent) -> String { + serde_json::to_string(e).unwrap() + } + + fn de_action(s: &str) -> IpcAction { + serde_json::from_str(s).unwrap() + } + + // ── IpcEvent ───────────────────────────────────────────────────────── + + #[test] + fn event_wake_word_detected_uses_snake_case_tag() { + let s = ser(&IpcEvent::WakeWordDetected); + assert!(s.contains(r#""event":"wake_word_detected""#)); + } + + #[test] + fn event_listening_serializes() { + let s = ser(&IpcEvent::Listening); + assert!(s.contains(r#""event":"listening""#)); + } + + #[test] + fn event_speech_recognized_carries_text() { + let s = ser(&IpcEvent::SpeechRecognized { text: "привет".into() }); + assert!(s.contains(r#""event":"speech_recognized""#)); + assert!(s.contains(r#""text":"привет""#)); + } + + #[test] + fn event_llm_reply_carries_text() { + let s = ser(&IpcEvent::LlmReply { text: "hello".into() }); + assert!(s.contains(r#""event":"llm_reply""#)); + assert!(s.contains(r#""text":"hello""#)); + } + + #[test] + fn event_command_executed_carries_id_and_success() { + let s = ser(&IpcEvent::CommandExecuted { + id: "volume.set".into(), + success: true, + }); + assert!(s.contains(r#""event":"command_executed""#)); + assert!(s.contains(r#""id":"volume.set""#)); + assert!(s.contains(r#""success":true"#)); + } + + #[test] + fn event_idle_minimal_payload() { + let s = ser(&IpcEvent::Idle); + assert!(s.contains(r#""event":"idle""#)); + // Should have no extra fields beyond the tag. + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v.as_object().unwrap().len(), 1); + } + + #[test] + fn event_error_carries_message() { + let s = ser(&IpcEvent::Error { message: "boom".into() }); + assert!(s.contains(r#""event":"error""#)); + assert!(s.contains(r#""message":"boom""#)); + } + + #[test] + fn event_health_snapshot_full_shape() { + let s = ser(&IpcEvent::HealthSnapshot { + tts_backend: "piper".into(), + llm_backend: "groq".into(), + llm_model: Some("llama-3.3-70b-versatile".into()), + active_profile: "work".into(), + memory_facts: 12, + scheduled_tasks: 3, + language: "ru".into(), + version: Some("0.0.1".into()), + }); + assert!(s.contains(r#""event":"health_snapshot""#)); + assert!(s.contains(r#""tts_backend":"piper""#)); + assert!(s.contains(r#""llm_backend":"groq""#)); + assert!(s.contains(r#""llm_model":"llama-3.3-70b-versatile""#)); + assert!(s.contains(r#""active_profile":"work""#)); + assert!(s.contains(r#""memory_facts":12"#)); + assert!(s.contains(r#""scheduled_tasks":3"#)); + assert!(s.contains(r#""language":"ru""#)); + } + + #[test] + fn event_health_snapshot_handles_null_model() { + let s = ser(&IpcEvent::HealthSnapshot { + tts_backend: "sapi".into(), + llm_backend: "none".into(), + llm_model: None, + active_profile: "default".into(), + memory_facts: 0, + scheduled_tasks: 0, + language: "ru".into(), + version: None, + }); + assert!(s.contains(r#""llm_model":null"#)); + assert!(s.contains(r#""version":null"#)); + } + + // ── IpcAction ──────────────────────────────────────────────────────── + + #[test] + fn action_stop_parses() { + let a = de_action(r#"{"action":"stop"}"#); + assert!(matches!(a, IpcAction::Stop)); + } + + #[test] + fn action_ping_parses() { + let a = de_action(r#"{"action":"ping"}"#); + assert!(matches!(a, IpcAction::Ping)); + } + + #[test] + fn action_text_command_parses_payload() { + let a = de_action(r#"{"action":"text_command","text":"какая погода"}"#); + match a { + IpcAction::TextCommand { text } => assert_eq!(text, "какая погода"), + _ => panic!("wrong variant"), + } + } + + #[test] + fn action_set_muted_parses_bool() { + let a = de_action(r#"{"action":"set_muted","muted":true}"#); + match a { + IpcAction::SetMuted { muted } => assert!(muted), + _ => panic!("wrong variant"), + } + } + + #[test] + fn action_switch_llm_parses_backend_name() { + let a = de_action(r#"{"action":"switch_llm","backend":"ollama"}"#); + match a { + IpcAction::SwitchLlm { backend } => assert_eq!(backend, "ollama"), + _ => panic!("wrong variant"), + } + } + + #[test] + fn action_reload_llm_parses() { + let a = de_action(r#"{"action":"reload_llm"}"#); + assert!(matches!(a, IpcAction::ReloadLlm)); + } + + #[test] + fn action_query_health_parses() { + let a = de_action(r#"{"action":"query_health"}"#); + assert!(matches!(a, IpcAction::QueryHealth)); + } + + #[test] + fn action_unknown_variant_fails() { + let r = serde_json::from_str::(r#"{"action":"do_a_barrel_roll"}"#); + assert!(r.is_err()); + } + + #[test] + fn action_missing_required_field_fails() { + // switch_llm requires "backend" payload + let r = serde_json::from_str::(r#"{"action":"switch_llm"}"#); + assert!(r.is_err()); + } + + // ── Cross-field stability ──────────────────────────────────────────── + + #[test] + fn event_tag_field_is_always_event() { + // Documented contract: every event has an "event" key with snake-case tag. + for variant in [ + IpcEvent::WakeWordDetected, + IpcEvent::Listening, + IpcEvent::Idle, + IpcEvent::Started, + IpcEvent::Stopping, + IpcEvent::Pong, + IpcEvent::RevealWindow, + ] { + let s = ser(&variant); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert!(v.get("event").is_some(), "missing 'event' tag in: {}", s); + assert!(v["event"].as_str().unwrap().chars() + .all(|c| c.is_ascii_lowercase() || c == '_'), + "non-snake-case tag in: {}", s); + } + } +} diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index 1b1968c..4740467 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -48,6 +48,31 @@ pub mod audio_buffer; #[cfg(feature = "lua")] pub mod lua; +pub mod text_utils; + +pub mod runtime_config; + +pub mod tts; + +pub mod long_term_memory; + +pub mod profiles; + +pub mod scheduler; + +pub mod macros; + +pub mod plugins; + +pub mod wake_trainer; + +pub mod idle_banter; + +pub mod recognition_log; + +#[cfg(feature = "lua")] +pub mod toast; + #[cfg(feature = "llm")] pub mod llm; diff --git a/crates/jarvis-core/src/listener/rustpotter.rs b/crates/jarvis-core/src/listener/rustpotter.rs index f0ca5b2..5dc72e6 100644 --- a/crates/jarvis-core/src/listener/rustpotter.rs +++ b/crates/jarvis-core/src/listener/rustpotter.rs @@ -3,7 +3,7 @@ use std::sync::Mutex; use once_cell::sync::OnceCell; use rustpotter::Rustpotter; -use crate::config; +use crate::{config, APP_CONFIG_DIR, DB}; // store rustpotter instance static RUSTPOTTER: OnceCell> = OnceCell::new(); @@ -12,38 +12,87 @@ pub fn init() -> Result<(), ()> { let rustpotter_config = config::RUSTPOTTER_DEFAULT_CONFIG; // create rustpotter instance - match Rustpotter::new(&rustpotter_config) { - Ok(mut rinstance) => { - // success - // wake word files list - // @TODO. Make it configurable via GUI for custom user voice. - let rustpotter_wake_word_files: [&str; 1] = [ - "resources/rustpotter/jarvis-default.rpw", - // "rustpotter/jarvis-community-1.rpw", - // "rustpotter/jarvis-community-2.rpw", - // "rustpotter/jarvis-community-3.rpw", - // "rustpotter/jarvis-community-4.rpw", - // "rustpotter/jarvis-community-5.rpw", - ]; - - // load wake word files - for rpw in rustpotter_wake_word_files { - // @TODO: Change wakeword key to something else? - if let Err(e) = rinstance.add_wakeword_from_file(rpw, rpw) { - error!("Failed to load wakeword file '{}': {}", rpw, e); - } - } - - // store - let _ = RUSTPOTTER.set(Mutex::new(rinstance)); - } + let mut rinstance = match Rustpotter::new(&rustpotter_config) { + Ok(r) => r, Err(msg) => { error!("Rustpotter failed to initialize.\nError details: {}", msg); - return Err(()); } + }; + + // Rustpotter accepts MULTIPLE wake-word models simultaneously — each + // trained on a different voice / acoustic profile. We load: + // + // 1. The bundled default — works for everyone but isn't tuned for + // this user's exact voice/mic/room. + // 2. EVERY user-trained .rpw file in APP_CONFIG_DIR/wake_words/. + // The user creates these via /wake-trainer; each adds robustness + // to detection without disabling the default. + // 3. (Legacy) settings.custom_wake_word — kept for back-compat when + // the user used to pin one specific custom model. If still set, + // it's just one of the files already loaded in step 2 — no-op. + let mut loaded_any = false; + + const DEFAULT: &str = "resources/rustpotter/jarvis-default.rpw"; + match rinstance.add_wakeword_from_file(DEFAULT, DEFAULT) { + Ok(_) => { + info!("Loaded bundled wake-word: {}", DEFAULT); + loaded_any = true; + } + Err(e) => warn!("Default wakeword unavailable ({}): {}", DEFAULT, e), + } + + if let Some(cfg_dir) = APP_CONFIG_DIR.get() { + let trained_dir = cfg_dir.join("wake_words"); + if let Ok(read) = std::fs::read_dir(&trained_dir) { + let mut count = 0usize; + for entry in read.flatten() { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) != Some("rpw") { + continue; + } + let path_str = p.to_string_lossy().to_string(); + match rinstance.add_wakeword_from_file(&path_str, &path_str) { + Ok(_) => { + info!("Loaded user-trained wake-word: {}", path_str); + loaded_any = true; + count += 1; + } + Err(e) => warn!("Skip user wake-word '{}': {}", path_str, e), + } + } + if count > 0 { + info!("{} user-trained wake-word model(s) active", count); + } + } } + // Surface the legacy `custom_wake_word` setting if it points somewhere + // ELSE than wake_words/. Pre-step-2 installs may have set this to an + // arbitrary location; we still honour it. + if let Some(db) = DB.get() { + let stem = db.read().custom_wake_word.clone(); + if !stem.is_empty() { + if let Some(cfg_dir) = APP_CONFIG_DIR.get() { + let inferred = cfg_dir.join("wake_words").join(format!("{}.rpw", stem)); + if inferred.is_file() { + // Already loaded in step 2 above. No-op. + } else { + warn!( + "settings.custom_wake_word = '{}' but {} not present (using bundled + trained only)", + stem, + inferred.display() + ); + } + } + } + } + + if !loaded_any { + error!("No wake-word models loaded; Rustpotter will not detect anything."); + } + + let _ = RUSTPOTTER.set(Mutex::new(rinstance)); Ok(()) } diff --git a/crates/jarvis-core/src/llm/client.rs b/crates/jarvis-core/src/llm/client.rs index 7c0ce91..c9baf47 100644 --- a/crates/jarvis-core/src/llm/client.rs +++ b/crates/jarvis-core/src/llm/client.rs @@ -2,12 +2,22 @@ use serde::{Deserialize, Serialize}; use std::env; use thiserror::Error; +// Groq (cloud, fast, free tier). pub const DEFAULT_BASE_URL: &str = "https://api.groq.com/openai/v1"; pub const DEFAULT_MODEL: &str = "llama-3.3-70b-versatile"; pub const ENV_TOKEN: &str = "GROQ_TOKEN"; pub const ENV_BASE_URL: &str = "GROQ_BASE_URL"; pub const ENV_MODEL: &str = "GROQ_MODEL"; +// Ollama (local, offline, privacy). Uses OpenAI-compatible /v1 endpoint. +pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://localhost:11434/v1"; +pub const OLLAMA_DEFAULT_MODEL: &str = "qwen2.5:3b"; +pub const ENV_OLLAMA_BASE_URL: &str = "OLLAMA_BASE_URL"; +pub const ENV_OLLAMA_MODEL: &str = "OLLAMA_MODEL"; + +// Top-level switch: JARVIS_LLM=groq|ollama (default: groq if GROQ_TOKEN set, else ollama). +pub const ENV_LLM_BACKEND: &str = "JARVIS_LLM"; + #[derive(Debug, Error)] pub enum ConfigError { #[error("missing environment variable: {0}")] @@ -70,9 +80,25 @@ struct ResponseMessage { content: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LlmBackend { + Groq, + Ollama, +} + +impl LlmBackend { + pub fn name(&self) -> &'static str { + match self { + LlmBackend::Groq => "groq", + LlmBackend::Ollama => "ollama", + } + } +} + pub struct LlmClient { + backend: LlmBackend, base_url: String, - api_key: String, + api_key: String, // empty for Ollama model: String, http: reqwest::blocking::Client, } @@ -82,20 +108,64 @@ impl LlmClient { base_url: impl Into, api_key: impl Into, model: impl Into, + ) -> Self { + Self::new_with_backend(LlmBackend::Groq, base_url, api_key, model) + } + + pub fn new_with_backend( + backend: LlmBackend, + base_url: impl Into, + api_key: impl Into, + model: impl Into, ) -> Self { Self { + backend, base_url: base_url.into(), api_key: api_key.into(), model: model.into(), - http: reqwest::blocking::Client::new(), + http: reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .unwrap_or_else(|_| reqwest::blocking::Client::new()), } } - pub fn from_env() -> Result { + pub fn groq() -> Result { let api_key = env::var(ENV_TOKEN).map_err(|_| ConfigError::MissingEnv(ENV_TOKEN))?; let base_url = env::var(ENV_BASE_URL).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()); let model = env::var(ENV_MODEL).unwrap_or_else(|_| DEFAULT_MODEL.to_string()); - Ok(Self::new(base_url, api_key, model)) + Ok(Self::new_with_backend(LlmBackend::Groq, base_url, api_key, model)) + } + + pub fn ollama() -> Self { + let base_url = env::var(ENV_OLLAMA_BASE_URL).unwrap_or_else(|_| OLLAMA_DEFAULT_BASE_URL.to_string()); + let model = env::var(ENV_OLLAMA_MODEL).unwrap_or_else(|_| OLLAMA_DEFAULT_MODEL.to_string()); + Self::new_with_backend(LlmBackend::Ollama, base_url, "", model) + } + + /// Pick a backend based on `JARVIS_LLM` env var; auto-detect if unset. + /// Default order: explicit JARVIS_LLM, else Groq if GROQ_TOKEN present, else Ollama. + pub fn from_env() -> Result { + match env::var(ENV_LLM_BACKEND).ok().map(|s| s.trim().to_lowercase()).as_deref() { + Some("ollama") => Ok(Self::ollama()), + Some("groq") => Self::groq(), + Some("") | None => { + // Auto: Groq if token present, else Ollama + if env::var(ENV_TOKEN).is_ok() { + Self::groq() + } else { + Ok(Self::ollama()) + } + } + Some(other) => { + log::warn!("Unknown JARVIS_LLM='{}'; falling back to Groq", other); + Self::groq() + } + } + } + + pub fn backend(&self) -> LlmBackend { + self.backend } pub fn model(&self) -> &str { @@ -103,21 +173,30 @@ impl LlmClient { } pub fn complete(&self, messages: &[ChatMessage], max_tokens: u32) -> Result { + self.complete_with(messages, max_tokens, 0.7, 1.0) + } + + pub fn complete_with( + &self, + messages: &[ChatMessage], + max_tokens: u32, + temperature: f32, + top_p: f32, + ) -> Result { let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); let body = ChatRequest { model: &self.model, messages, max_tokens, - temperature: 0.7, - top_p: 1.0, + temperature, + top_p, }; - let resp = self - .http - .post(&url) - .bearer_auth(&self.api_key) - .json(&body) - .send()?; + let mut req = self.http.post(&url).json(&body); + if !self.api_key.is_empty() { + req = req.bearer_auth(&self.api_key); + } + let resp = req.send()?; let status = resp.status(); let text = resp.text()?; @@ -163,10 +242,10 @@ mod tests { } #[test] - fn from_env_fails_when_token_missing() { + fn groq_fails_when_token_missing() { let prev = env::var(ENV_TOKEN).ok(); unsafe { env::remove_var(ENV_TOKEN); } - let result = LlmClient::from_env(); + let result = LlmClient::groq(); let err = match result { Ok(_) => panic!("expected ConfigError when {ENV_TOKEN} is unset"), Err(e) => e, @@ -179,10 +258,92 @@ mod tests { } } + #[test] + fn ollama_works_without_token() { + let client = LlmClient::ollama(); + assert_eq!(client.backend(), LlmBackend::Ollama); + // Default model unless OLLAMA_MODEL overrides. + } + #[test] fn chat_message_helpers_set_role() { assert_eq!(ChatMessage::user("hi").role, "user"); assert_eq!(ChatMessage::system("ctx").role, "system"); assert_eq!(ChatMessage::assistant("ok").role, "assistant"); } + + #[test] + fn chat_message_serde_round_trip() { + let m = ChatMessage::user("привет мир"); + let json = serde_json::to_string(&m).unwrap(); + assert!(json.contains(r#""role":"user""#)); + assert!(json.contains(r#""content":"привет мир""#)); + let back: ChatMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(back.role, "user"); + assert_eq!(back.content, "привет мир"); + } + + #[test] + fn ollama_client_has_no_api_key() { + let c = LlmClient::ollama(); + // We can't directly read api_key (private), but can verify backend. + assert_eq!(c.backend(), LlmBackend::Ollama); + } + + #[test] + fn groq_client_carries_token() { + // SAFETY: tests are racy w/ env, but we use a unique-named token to scope. + unsafe { + env::set_var(ENV_TOKEN, "test-token-12345"); + } + let c = LlmClient::groq().expect("with token, groq() should succeed"); + assert_eq!(c.backend(), LlmBackend::Groq); + assert_eq!(c.model(), DEFAULT_MODEL); + // Don't unset — other tests may have set it; this is best-effort. + } + + #[test] + fn ollama_default_model_can_be_overridden() { + unsafe { + env::set_var(ENV_OLLAMA_MODEL, "llama3.2:latest"); + } + let c = LlmClient::ollama(); + assert_eq!(c.model(), "llama3.2:latest"); + unsafe { env::remove_var(ENV_OLLAMA_MODEL); } + } + + #[test] + fn parses_response_with_unicode_content() { + let raw = r#"{ + "choices": [ + { "message": { "role": "assistant", "content": "Доброе утро, сэр." } } + ] + }"#; + let parsed: ChatResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(parsed.choices[0].message.content, "Доброе утро, сэр."); + } + + #[test] + fn empty_choices_yields_empty_response_err() { + let raw = r#"{"choices":[]}"#; + let parsed: ChatResponse = serde_json::from_str(raw).unwrap(); + assert!(parsed.choices.is_empty()); + } + + /// Smoke test that only runs when an Ollama server is reachable. + /// Run with: cargo test --lib llm::client::tests::ollama_smoke -- --ignored + #[test] + #[ignore] + fn ollama_smoke() { + let client = LlmClient::ollama(); + match client.complete(&[ChatMessage::user("Say only 'hi' and nothing else.")], 20) { + Ok(reply) => { + println!("Ollama replied: {}", reply); + assert!(!reply.is_empty()); + } + Err(e) => { + eprintln!("Ollama unreachable (expected if not running): {}", e); + } + } + } } diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs index 7d2af8d..89d6176 100644 --- a/crates/jarvis-core/src/llm/history.rs +++ b/crates/jarvis-core/src/llm/history.rs @@ -1,9 +1,15 @@ use super::client::ChatMessage; +use std::fs; +use std::path::PathBuf; pub struct ConversationHistory { system: Option, turns: Vec, max_turns: usize, + /// If set, every push/clear/pop atomically writes the turns to this path, + /// so a daemon restart can pick up where the user left off. Excludes the + /// system prompt (which comes from the running config, not from disk). + persist_path: Option, } impl ConversationHistory { @@ -12,6 +18,7 @@ impl ConversationHistory { system: Some(ChatMessage::system(system_prompt)), turns: Vec::new(), max_turns: max_turns.max(1), + persist_path: None, } } @@ -20,17 +27,42 @@ impl ConversationHistory { system: None, turns: Vec::new(), max_turns: max_turns.max(1), + persist_path: None, } } + /// Tell history where to persist on every mutation. Tries to load any + /// existing turns from `path` first so a daemon restart can pick up the + /// thread. The system prompt is NOT loaded — it always comes from the + /// current `new()` call so prompt edits take effect immediately. + pub fn with_persistence>(mut self, path: P) -> Self { + let p = path.into(); + if let Ok(text) = fs::read_to_string(&p) { + if let Ok(turns) = serde_json::from_str::>(&text) { + for m in turns { + if m.role == "user" { + self.turns.push(ChatMessage::user(m.content)); + } else if m.role == "assistant" { + self.turns.push(ChatMessage::assistant(m.content)); + } + } + self.truncate(); + } + } + self.persist_path = Some(p); + self + } + pub fn push_user(&mut self, content: impl Into) { self.turns.push(ChatMessage::user(content)); self.truncate(); + self.persist(); } pub fn push_assistant(&mut self, content: impl Into) { self.turns.push(ChatMessage::assistant(content)); self.truncate(); + self.persist(); } pub fn snapshot(&self) -> Vec { @@ -48,22 +80,64 @@ impl ConversationHistory { pub fn clear(&mut self) { self.turns.clear(); + self.persist(); } pub fn pop_last_user(&mut self) -> Option { if matches!(self.turns.last(), Some(m) if m.role == "user") { - self.turns.pop() + let popped = self.turns.pop(); + self.persist(); + popped } else { None } } + /// Return the most recent assistant message, or None if none yet. + pub fn last_assistant(&self) -> Option<&ChatMessage> { + self.turns.iter().rev().find(|m| m.role == "assistant") + } + fn truncate(&mut self) { if self.turns.len() > self.max_turns { let drop = self.turns.len() - self.max_turns; self.turns.drain(0..drop); } } + + /// Atomically write turns to `persist_path` if set. Best-effort: errors + /// are logged but never panic — chat history is convenience, not critical. + fn persist(&self) { + let path = match &self.persist_path { + Some(p) => p, + None => return, + }; + let json = match serde_json::to_string_pretty(&self.turns) { + Ok(s) => s, + Err(e) => { + log::warn!("LLM history serialise failed: {}", e); + return; + } + }; + let tmp = path.with_extension("json.tmp"); + if let Err(e) = fs::write(&tmp, json) { + log::warn!("LLM history write {} failed: {}", tmp.display(), e); + return; + } + if let Err(e) = fs::rename(&tmp, path) { + log::warn!("LLM history rename {} → {} failed: {}", tmp.display(), path.display(), e); + } + } + + /// Number of stored turns (excluding the system prompt). Public so callers + /// can show a "remembered N exchanges" hint in the GUI/voice. + pub fn len(&self) -> usize { + self.turns.len() + } + + pub fn is_empty(&self) -> bool { + self.turns.is_empty() + } } #[cfg(test)] @@ -135,4 +209,68 @@ mod tests { assert_eq!(snap.len(), 1); assert_eq!(snap[0].role, "system"); } + + #[test] + fn len_and_is_empty_track_turns_only() { + let mut h = ConversationHistory::new("sys", 4); + assert!(h.is_empty()); + assert_eq!(h.len(), 0); + h.push_user("u1"); + assert_eq!(h.len(), 1); + h.push_assistant("a1"); + assert_eq!(h.len(), 2); + h.clear(); + assert!(h.is_empty()); + } + + #[test] + fn persistence_round_trip_via_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("history.json"); + + { + let mut h = ConversationHistory::new("you are jarvis", 8) + .with_persistence(&path); + h.push_user("привет"); + h.push_assistant("Слушаю, сэр."); + h.push_user("сколько время"); + } + // File written on each mutation; tempdir still alive until end of test. + assert!(path.is_file(), "persist file must exist"); + + let restored = ConversationHistory::new("you are jarvis", 8) + .with_persistence(&path); + let snap = restored.snapshot(); + // 1 system + 3 turns + assert_eq!(snap.len(), 4); + assert_eq!(snap[1].role, "user"); + assert_eq!(snap[1].content, "привет"); + assert_eq!(snap[2].role, "assistant"); + assert_eq!(snap[3].content, "сколько время"); + } + + #[test] + fn clear_wipes_persisted_file_contents() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("history.json"); + let mut h = ConversationHistory::new("sys", 4) + .with_persistence(&path); + h.push_user("u"); + h.push_assistant("a"); + h.clear(); + + let restored = ConversationHistory::new("sys", 4) + .with_persistence(&path); + assert!(restored.is_empty(), "restored history must be empty"); + } + + #[test] + fn corrupt_persist_file_is_tolerated() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("history.json"); + std::fs::write(&path, b"this is not json {{{").unwrap(); + let h = ConversationHistory::new("sys", 4).with_persistence(&path); + // Garbage in file → start fresh, don't panic. + assert!(h.is_empty()); + } } diff --git a/crates/jarvis-core/src/llm/mod.rs b/crates/jarvis-core/src/llm/mod.rs index e4ba4ee..7c9e70e 100644 --- a/crates/jarvis-core/src/llm/mod.rs +++ b/crates/jarvis-core/src/llm/mod.rs @@ -2,7 +2,195 @@ mod client; mod history; pub use client::{ - ChatMessage, ConfigError, LlmClient, LlmError, + ChatMessage, ConfigError, LlmBackend, LlmClient, LlmError, DEFAULT_BASE_URL, DEFAULT_MODEL, ENV_BASE_URL, ENV_MODEL, ENV_TOKEN, + OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, }; pub use history::ConversationHistory; + +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use std::sync::Arc; + +// ─── Shared conversation history ─────────────────────────────────────────── +// +// Single global history so the LLM fallback chat, the Lua reset/repeat helpers, +// and any future IPC handler all operate on the same buffer. Initialised on +// boot via `init_history(system_prompt, max_turns)`. + +static HISTORY: Lazy>> = Lazy::new(|| RwLock::new(None)); + +const HISTORY_FILE: &str = "llm_history.json"; + +pub fn init_history(system_prompt: impl Into, max_turns: usize) { + let mut h = ConversationHistory::new(system_prompt, max_turns); + if let Some(cfg) = crate::APP_CONFIG_DIR.get() { + let path = cfg.join(HISTORY_FILE); + h = h.with_persistence(path); + log::info!( + "LLM conversation history persistence enabled ({} turns restored)", + h.len() + ); + } + *HISTORY.write() = Some(h); +} + +pub fn history_push_user(content: impl Into) { + if let Some(h) = HISTORY.write().as_mut() { + h.push_user(content); + } +} + +pub fn history_push_assistant(content: impl Into) { + if let Some(h) = HISTORY.write().as_mut() { + h.push_assistant(content); + } +} + +pub fn history_snapshot() -> Vec { + HISTORY.read().as_ref().map(|h| h.snapshot()).unwrap_or_default() +} + +pub fn history_clear() { + if let Some(h) = HISTORY.write().as_mut() { + h.clear(); + } +} + +pub fn history_pop_last_user() { + if let Some(h) = HISTORY.write().as_mut() { + h.pop_last_user(); + } +} + +/// Return the most recent assistant message text, or None. +pub fn history_last_assistant() -> Option { + HISTORY.read().as_ref() + .and_then(|h| h.last_assistant().map(|m| m.content.clone())) +} + +/// Shared mutable LLM client. Modules read via `current()`; the user can +/// hot-swap backends at runtime via `swap_to(LlmBackend::Ollama)` etc. +/// +/// `None` means LLM is disabled (no GROQ_TOKEN and no Ollama reachable, or +/// `init_global()` was never called). +static GLOBAL: Lazy>>> = Lazy::new(|| RwLock::new(None)); + +/// Initialise the global client. Precedence: +/// 1. Settings DB `llm_backend` field (if non-empty) +/// 2. `JARVIS_LLM` env var +/// 3. Auto-detect (Groq if `GROQ_TOKEN`, else Ollama) +/// +/// Idempotent — replaces any previous global. Returns Ok even when the chosen +/// backend can't be probed: an Ollama server may not be running yet but we +/// stash the client anyway and let calls fail on first request. +pub fn init_global() -> Result<(), ConfigError> { + // Try DB-persisted choice first. + let persisted = crate::DB.get().and_then(|db| { + let s = db.read(); + let backend = s.llm_backend.trim().to_string(); + if backend.is_empty() { None } else { parse_backend(&backend) } + }); + + let c = match persisted { + Some(LlmBackend::Groq) => LlmClient::groq()?, + Some(LlmBackend::Ollama) => LlmClient::ollama(), + None => LlmClient::from_env()?, + }; + + log::info!("LLM global initialised: backend={}, model={}", c.backend().name(), c.model()); + *GLOBAL.write() = Some(Arc::new(c)); + Ok(()) +} + +/// Return a clone of the active client. Cheap (Arc). +pub fn current() -> Option> { + GLOBAL.read().clone() +} + +/// Name of the currently active backend, or "none" if not initialised. +pub fn current_backend_name() -> &'static str { + match GLOBAL.read().as_ref().map(|c| c.backend()) { + Some(LlmBackend::Groq) => "groq", + Some(LlmBackend::Ollama) => "ollama", + None => "none", + } +} + +/// Hot-swap to a different backend. Returns the new backend's name on success. +/// Persists the choice to the settings DB so it survives restart. +pub fn swap_to(backend: LlmBackend) -> Result<&'static str, ConfigError> { + let c = match backend { + LlmBackend::Groq => LlmClient::groq()?, + LlmBackend::Ollama => LlmClient::ollama(), + }; + let name = c.backend().name(); + log::info!("LLM swapped → backend={}, model={}", name, c.model()); + *GLOBAL.write() = Some(Arc::new(c)); + + // Persist to settings DB (best-effort — log on failure, don't propagate). + if let Some(db) = crate::DB.get() { + let snapshot = { + let mut s = db.write(); + s.llm_backend = name.to_string(); + s.clone() + }; + if let Err(e) = crate::db::save_settings(&snapshot) { + log::warn!("LLM swap: failed to persist to settings DB: {}", e); + } else { + log::info!("LLM choice persisted: {}", name); + } + } + + Ok(name) +} + +/// Parse a backend name (case-insensitive) into the enum. +pub fn parse_backend(name: &str) -> Option { + match name.trim().to_lowercase().as_str() { + "groq" | "cloud" | "облако" | "клауд" => Some(LlmBackend::Groq), + "ollama" | "local" | "локал" | "локальн" | "локальный" => Some(LlmBackend::Ollama), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_backend_accepts_english() { + assert_eq!(parse_backend("groq"), Some(LlmBackend::Groq)); + assert_eq!(parse_backend("ollama"), Some(LlmBackend::Ollama)); + assert_eq!(parse_backend("cloud"), Some(LlmBackend::Groq)); + assert_eq!(parse_backend("local"), Some(LlmBackend::Ollama)); + } + + #[test] + fn parse_backend_accepts_russian() { + assert_eq!(parse_backend("облако"), Some(LlmBackend::Groq)); + assert_eq!(parse_backend("локальный"), Some(LlmBackend::Ollama)); + assert_eq!(parse_backend("локал"), Some(LlmBackend::Ollama)); + } + + #[test] + fn parse_backend_case_insensitive() { + assert_eq!(parse_backend("OLLAMA"), Some(LlmBackend::Ollama)); + assert_eq!(parse_backend(" Groq "), Some(LlmBackend::Groq)); + } + + #[test] + fn parse_backend_rejects_unknown() { + assert_eq!(parse_backend("openai"), None); + assert_eq!(parse_backend("claude"), None); + assert_eq!(parse_backend(""), None); + } + + #[test] + fn current_backend_name_when_uninitialised_is_none() { + // Note: in the same process as other tests, GLOBAL may have been + // initialised by ollama_works_without_token. So this assertion is + // best-effort — just exercise the code path. + let _ = current_backend_name(); + } +} diff --git a/crates/jarvis-core/src/long_term_memory.rs b/crates/jarvis-core/src/long_term_memory.rs new file mode 100644 index 0000000..951b76e --- /dev/null +++ b/crates/jarvis-core/src/long_term_memory.rs @@ -0,0 +1,366 @@ +//! IMBA-2: Long-term memory store. +//! +//! Persistent fact storage for J.A.R.V.I.S.. Lets the user say things like +//! "запомни что у меня собака Бася" / "я люблю чай улун" and have the +//! assistant recall them in later sessions. +//! +//! Storage: JSON file at `/long_term_memory.json`. Atomic +//! write-through (write-then-rename) to survive crashes. Simple substring +//! search for v1; vector search via fastembed is a future upgrade. +//! +//! Lua access: +//! jarvis.memory.remember(key, value) +//! jarvis.memory.recall(key) -- returns value or nil +//! jarvis.memory.search(query, n?) -- returns array of {key, value} matches +//! jarvis.memory.forget(key) +//! jarvis.memory.all() -- returns full table (debug) +//! +//! On-disk record fields: key, value, created_at, last_used_at, use_count. +//! Auto-decay: entries unused for 90 days are returned last in search results +//! (not deleted automatically; explicit `forget` only). + +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::APP_CONFIG_DIR; + +const FILE_NAME: &str = "long_term_memory.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryRecord { + pub key: String, + pub value: String, + pub created_at: i64, + pub last_used_at: i64, + #[serde(default)] + pub use_count: u64, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +struct Store { + #[serde(default)] + entries: BTreeMap, +} + +struct State { + path: PathBuf, + store: RwLock, +} + +static STATE: OnceCell = OnceCell::new(); + +pub fn init() -> Result<(), String> { + if STATE.get().is_some() { + return Ok(()); + } + + let dir = APP_CONFIG_DIR + .get() + .ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?; + let path = dir.join(FILE_NAME); + + let store = load_or_empty(&path); + info!( + "Long-term memory loaded: {} entries from {}", + store.entries.len(), + path.display() + ); + + STATE.set(State { path, store: RwLock::new(store) }) + .map_err(|_| "long_term_memory already initialised".to_string())?; + Ok(()) +} + +fn load_or_empty(path: &PathBuf) -> Store { + if !path.is_file() { + return Store::default(); + } + match std::fs::read_to_string(path) { + Ok(s) => match serde_json::from_str::(&s) { + Ok(store) => store, + Err(e) => { + warn!("Corrupt memory file {}: {} — starting empty", path.display(), e); + Store::default() + } + }, + Err(e) => { + warn!("Cannot read {}: {} — starting empty", path.display(), e); + Store::default() + } + } +} + +fn persist(state: &State) { + let store = state.store.read(); + let tmp = state.path.with_extension("json.tmp"); + let json = match serde_json::to_string_pretty(&*store) { + Ok(s) => s, + Err(e) => { + warn!("Memory serialize failed: {}", e); + return; + } + }; + if let Err(e) = std::fs::write(&tmp, json) { + warn!("Memory write failed to {}: {}", tmp.display(), e); + return; + } + if let Err(e) = std::fs::rename(&tmp, &state.path) { + warn!("Memory rename failed: {}", e); + } +} + +fn now_secs() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +pub(crate) fn normalize_key(k: &str) -> String { + k.trim().to_lowercase() +} + +/// Filter+rank logic shared between `search` and tests. Public for unit tests +/// to bypass the global state. +pub(crate) fn search_in<'a>( + entries: impl IntoIterator, + query: &str, + limit: usize, +) -> Vec { + let nq = normalize_key(query); + if nq.is_empty() { + return Vec::new(); + } + let mut hits: Vec = entries.into_iter() + .filter(|r| r.key.contains(&nq) || r.value.to_lowercase().contains(&nq)) + .cloned() + .collect(); + hits.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at)); + hits.truncate(limit.max(1)); + hits +} + +/// Store / overwrite a fact. +pub fn remember(key: &str, value: &str) -> Result<(), String> { + let state = STATE.get().ok_or_else(|| "memory not init".to_string())?; + let nk = normalize_key(key); + if nk.is_empty() { + return Err("empty key".into()); + } + let now = now_secs(); + { + let mut store = state.store.write(); + let entry = store.entries.entry(nk.clone()).or_insert(MemoryRecord { + key: nk.clone(), + value: value.to_string(), + created_at: now, + last_used_at: now, + use_count: 0, + }); + entry.value = value.to_string(); + entry.last_used_at = now; + } + persist(state); + info!("Memory: remembered '{}' = '{}'", nk, value); + Ok(()) +} + +/// Retrieve a fact by exact key (case-insensitive). +pub fn recall(key: &str) -> Option { + let state = STATE.get()?; + let nk = normalize_key(key); + let now = now_secs(); + let value = { + let mut store = state.store.write(); + let entry = store.entries.get_mut(&nk)?; + entry.last_used_at = now; + entry.use_count += 1; + entry.value.clone() + }; + persist(state); + Some(value) +} + +/// Substring search (case-insensitive). Returns up to `limit` records ranked by +/// recency. Matches against both key and value. +pub fn search(query: &str, limit: usize) -> Vec { + let Some(state) = STATE.get() else { return Vec::new(); }; + let store = state.store.read(); + search_in(store.entries.values(), query, limit) +} + +/// Delete a fact. Returns true if it existed. +pub fn forget(key: &str) -> bool { + let Some(state) = STATE.get() else { return false; }; + let nk = normalize_key(key); + let removed = state.store.write().entries.remove(&nk).is_some(); + if removed { + persist(state); + info!("Memory: forgot '{}'", nk); + } + removed +} + +/// Wipe all stored facts. Returns the count of records removed. +/// Voice commands call this only after a confirmation prefix +/// ("точно забудь все" — not just "забудь все") to avoid disasters. +pub fn clear_all() -> usize { + let Some(state) = STATE.get() else { return 0; }; + let n = { + let mut store = state.store.write(); + let n = store.entries.len(); + store.entries.clear(); + n + }; + if n > 0 { + persist(state); + warn!("Memory: WIPED all {} facts", n); + } + n +} + +/// Snapshot of all records (for debug / GUI display). +pub fn all() -> Vec { + let Some(state) = STATE.get() else { return Vec::new(); }; + state.store.read().entries.values().cloned().collect() +} + +/// Build a system-prompt snippet containing relevant facts for the LLM, +/// based on a substring search of the user's prompt. Empty string if no matches. +pub fn build_context(prompt: &str, limit: usize) -> String { + let hits = search(prompt, limit); + build_context_from(&hits) +} + +/// Pure formatter used by both `build_context` and tests. +pub(crate) fn build_context_from(hits: &[MemoryRecord]) -> String { + if hits.is_empty() { + return String::new(); + } + let mut buf = String::with_capacity(256); + buf.push_str("Известные факты о пользователе (используй если уместно):\n"); + for h in hits { + buf.push_str(&format!("- {} = {}\n", h.key, h.value)); + } + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(key: &str, value: &str, last_used: i64) -> MemoryRecord { + MemoryRecord { + key: key.to_string(), + value: value.to_string(), + created_at: 0, + last_used_at: last_used, + use_count: 0, + } + } + + #[test] + fn normalize_key_lowercases_and_trims() { + assert_eq!(normalize_key(" HeLLo "), "hello"); + assert_eq!(normalize_key(""), ""); + assert_eq!(normalize_key("любимый ЧАЙ "), "любимый чай"); + } + + #[test] + fn search_in_matches_key_or_value() { + let entries = vec![ + rec("любимый чай", "улун", 100), + rec("любимый кофе", "арабика", 200), + rec("питомец", "собака бася", 300), + ]; + let hits = search_in(&entries, "чай", 5); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].key, "любимый чай"); + + let hits = search_in(&entries, "бася", 5); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].key, "питомец"); + } + + #[test] + fn search_in_ranks_by_recency() { + let entries = vec![ + rec("чай улун", "вкусный", 100), + rec("чай зеленый", "тоже", 300), + rec("чай черный", "обычный", 200), + ]; + let hits = search_in(&entries, "чай", 5); + assert_eq!(hits.len(), 3); + assert_eq!(hits[0].key, "чай зеленый"); // most recent + assert_eq!(hits[1].key, "чай черный"); + assert_eq!(hits[2].key, "чай улун"); + } + + #[test] + fn search_in_respects_limit() { + let entries = vec![ + rec("a", "x", 1), + rec("ab", "y", 2), + rec("abc", "z", 3), + ]; + let hits = search_in(&entries, "a", 2); + assert_eq!(hits.len(), 2); + } + + #[test] + fn search_in_empty_query_returns_nothing() { + let entries = vec![rec("a", "b", 0)]; + assert!(search_in(&entries, "", 5).is_empty()); + assert!(search_in(&entries, " ", 5).is_empty()); + } + + #[test] + fn build_context_empty_returns_empty_string() { + assert_eq!(build_context_from(&[]), ""); + } + + #[test] + fn build_context_lists_each_fact() { + let hits = vec![ + rec("чай", "улун", 0), + rec("кофе", "арабика", 0), + ]; + let ctx = build_context_from(&hits); + assert!(ctx.contains("чай = улун")); + assert!(ctx.contains("кофе = арабика")); + assert!(ctx.starts_with("Известные факты")); + } + + #[test] + fn memory_record_serde_round_trip() { + let r = rec("test", "value", 42); + let json = serde_json::to_string(&r).unwrap(); + let back: MemoryRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(back.key, r.key); + assert_eq!(back.value, r.value); + assert_eq!(back.last_used_at, r.last_used_at); + } + + #[test] + fn store_serde_round_trip() { + let mut store = Store::default(); + store.entries.insert("key1".into(), rec("key1", "val1", 100)); + store.entries.insert("key2".into(), rec("key2", "val2", 200)); + let json = serde_json::to_string(&store).unwrap(); + let back: Store = serde_json::from_str(&json).unwrap(); + assert_eq!(back.entries.len(), 2); + assert_eq!(back.entries.get("key1").unwrap().value, "val1"); + } + + #[test] + fn clear_all_returns_zero_when_state_uninit() { + // STATE may or may not be initialised depending on test order; the + // public function still needs to return safely (and 0) in either case. + // We just exercise the code path — exact count depends on global state. + let _ = clear_all(); + } +} diff --git a/crates/jarvis-core/src/lua/api.rs b/crates/jarvis-core/src/lua/api.rs index 8bda5a4..b34f6b5 100644 --- a/crates/jarvis-core/src/lua/api.rs +++ b/crates/jarvis-core/src/lua/api.rs @@ -4,4 +4,15 @@ pub mod context; pub mod http; pub mod fs; pub mod state; -pub mod system; \ No newline at end of file +pub mod system; +pub mod tts; +pub mod llm; +pub mod text; +pub mod memory; +pub mod profile; +pub mod vision; +pub mod scheduler; +pub mod cmd; +pub mod health; +pub mod macros; +pub mod banter; \ No newline at end of file diff --git a/crates/jarvis-core/src/lua/api/banter.rs b/crates/jarvis-core/src/lua/api/banter.rs new file mode 100644 index 0000000..84005b1 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/banter.rs @@ -0,0 +1,29 @@ +//! `jarvis.banter` — lua bindings for the idle-banter background module. +//! +//! Usage from voice packs: +//! jarvis.banter.fire() -- force-fire one remark now (returns bool) +//! jarvis.banter.pause() -- pause until resume() +//! jarvis.banter.resume() +//! jarvis.banter.enabled() -- true if env opt-in is set + +use mlua::{Lua, Table}; + +use crate::idle_banter; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let b = lua.create_table()?; + + b.set("fire", lua.create_function(|_, ()| Ok(idle_banter::fire_now()))?)?; + b.set("pause", lua.create_function(|_, ()| { + idle_banter::pause(); + Ok(()) + })?)?; + b.set("resume", lua.create_function(|_, ()| { + idle_banter::resume(); + Ok(()) + })?)?; + b.set("enabled", lua.create_function(|_, ()| Ok(idle_banter::enabled()))?)?; + + jarvis.set("banter", b)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/cmd.rs b/crates/jarvis-core/src/lua/api/cmd.rs new file mode 100644 index 0000000..ec50ee7 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/cmd.rs @@ -0,0 +1,81 @@ +//! `jarvis.cmd` — boilerplate-killer helpers for Lua command scripts. +//! +//! Most packs end with the same 3-line ritual: +//! +//! jarvis.speak(msg) +//! jarvis.audio.play_ok() +//! return { chain = false } +//! +//! With these helpers it's one call: +//! +//! return jarvis.cmd.ok(msg) +//! +//! Available helpers (each returns a `{ chain = false }` result table so the +//! pack can simply `return jarvis.cmd.ok(...)`): +//! +//! jarvis.cmd.ok(msg?) -- play_ok sound, optional spoken msg +//! jarvis.cmd.chain_ok(msg?) -- like ok() but returns {chain = true} +//! jarvis.cmd.error(msg?) -- play_error sound, optional spoken msg +//! jarvis.cmd.not_found(msg?) -- play_not_found sound, optional spoken msg +//! jarvis.cmd.silent_ok() -- only play_ok, no speech +//! jarvis.cmd.silent_error() -- only play_error, no speech + +use mlua::{Lua, Table}; + +use crate::tts::{self, SpeakOpts}; +use crate::voices; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let cmd = lua.create_table()?; + + cmd.set("ok", lua.create_function(|l, msg: Option| { + if let Some(text) = msg.filter(|s| !s.trim().is_empty()) { + tts::speak(&text, &SpeakOpts::default()); + } + voices::play_ok(); + result_table(l, false) + })?)?; + + cmd.set("chain_ok", lua.create_function(|l, msg: Option| { + if let Some(text) = msg.filter(|s| !s.trim().is_empty()) { + tts::speak(&text, &SpeakOpts::default()); + } + voices::play_ok(); + result_table(l, true) + })?)?; + + cmd.set("error", lua.create_function(|l, msg: Option| { + if let Some(text) = msg.filter(|s| !s.trim().is_empty()) { + tts::speak(&text, &SpeakOpts::default()); + } + voices::play_error(); + result_table(l, false) + })?)?; + + cmd.set("not_found", lua.create_function(|l, msg: Option| { + if let Some(text) = msg.filter(|s| !s.trim().is_empty()) { + tts::speak(&text, &SpeakOpts::default()); + } + voices::play_not_found(); + result_table(l, false) + })?)?; + + cmd.set("silent_ok", lua.create_function(|l, ()| { + voices::play_ok(); + result_table(l, false) + })?)?; + + cmd.set("silent_error", lua.create_function(|l, ()| { + voices::play_error(); + result_table(l, false) + })?)?; + + jarvis.set("cmd", cmd)?; + Ok(()) +} + +fn result_table(lua: &Lua, chain: bool) -> mlua::Result { + let t = lua.create_table()?; + t.set("chain", chain)?; + Ok(t) +} diff --git a/crates/jarvis-core/src/lua/api/health.rs b/crates/jarvis-core/src/lua/api/health.rs new file mode 100644 index 0000000..9887531 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/health.rs @@ -0,0 +1,50 @@ +//! `jarvis.health()` — debug snapshot of active runtime state. +//! +//! Returns a table that can be JSON-encoded for bug reports. Doesn't include +//! secrets (no API keys, no LLM history content). +//! +//! Example use from Lua: +//! local h = jarvis.health() +//! for k, v in pairs(h) do print(k, v) end + +use mlua::{Lua, Table}; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let health_fn = lua.create_function(|lua, ()| { + let t = lua.create_table()?; + + // TTS + t.set("tts_backend", crate::tts::backend().name())?; + + // LLM + t.set("llm_backend", crate::llm::current_backend_name())?; + if let Some(c) = crate::llm::current() { + t.set("llm_model", c.model().to_string())?; + } + + // Profile + t.set("active_profile", crate::profiles::active_name())?; + + // Memory size + t.set("memory_facts", crate::long_term_memory::all().len())?; + + // Scheduler size + t.set("scheduled_tasks", crate::scheduler::list().len())?; + + // Language + t.set("language", crate::i18n::get_language())?; + + // Voice from settings + if let Some(db) = crate::DB.get() { + let s = db.read(); + t.set("voice", s.voice.clone())?; + t.set("microphone", s.microphone)?; + t.set("vosk_model", s.vosk_model.clone())?; + t.set("noise_suppression", format!("{:?}", s.noise_suppression))?; + } + + Ok(t) + })?; + jarvis.set("health", health_fn)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/llm.rs b/crates/jarvis-core/src/lua/api/llm.rs new file mode 100644 index 0000000..b170b43 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/llm.rs @@ -0,0 +1,140 @@ +use mlua::{Lua, Table, Value}; + +use crate::llm::{self, ChatMessage}; + +// Lua-callable wrapper around jarvis_core::llm. +// Command scripts call: +// local reply = jarvis.llm( +// { {role='system', content='...'}, {role='user', content='...'} }, +// { temperature = 0.0, max_tokens = 64 } +// ) +// Returns assistant text (string) or nil on any failure. +// +// Backend selection is global (see `jarvis_core::llm::swap_to`). Lua scripts can: +// jarvis.llm_status() -> "groq" | "ollama" | "none" +// jarvis.llm_switch("ollama") -> name string on success, nil on failure +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let chat_fn = lua.create_function(|lua, (messages, opts): (Table, Option
)| { + let mut chat_messages = Vec::new(); + for pair in messages.sequence_values::
() { + let msg = pair?; + let role = msg.get::("role").unwrap_or_else(|_| "user".to_string()); + let content = msg.get::("content").unwrap_or_default(); + if content.is_empty() { + continue; + } + chat_messages.push(match role.as_str() { + "system" => ChatMessage::system(content), + "assistant" => ChatMessage::assistant(content), + _ => ChatMessage::user(content), + }); + } + if chat_messages.is_empty() { + return Ok(Value::Nil); + } + + let mut max_tokens: u32 = 256; + let mut temperature: f32 = 0.7; + let mut top_p: f32 = 1.0; + if let Some(o) = opts { + if let Ok(v) = o.get::("max_tokens") { max_tokens = v; } + if let Ok(v) = o.get::("temperature") { temperature = v; } + if let Ok(v) = o.get::("top_p") { top_p = v; } + } + + // Prefer the live global client (so runtime backend swap takes effect). + // Fall back to a one-shot client from env if global is not initialised. + let client = match llm::current() { + Some(c) => c, + None => { + match llm::LlmClient::from_env() { + Ok(c) => std::sync::Arc::new(c), + Err(e) => { + log::warn!("[Lua llm] no client: {}", e); + return Ok(Value::Nil); + } + } + } + }; + + match client.complete_with(&chat_messages, max_tokens, temperature, top_p) { + Ok(reply) => { + let s = lua.create_string(reply.trim())?; + Ok(Value::String(s)) + } + Err(e) => { + log::warn!("[Lua llm] request failed: {}", e); + Ok(Value::Nil) + } + } + })?; + jarvis.set("llm", chat_fn)?; + + // Current backend name ("groq" | "ollama" | "none"). + let status_fn = lua.create_function(|_, ()| { + Ok(llm::current_backend_name().to_string()) + })?; + jarvis.set("llm_status", status_fn)?; + + // Hot-swap backend. Accepts "groq"/"ollama"/"local"/"cloud"/"локальный"/"облако". + // Returns the new backend name on success, nil on failure. + let switch_fn = lua.create_function(|lua, name: String| { + match llm::parse_backend(&name) { + Some(b) => match llm::swap_to(b) { + Ok(actual) => { + let s = lua.create_string(actual)?; + Ok(Value::String(s)) + } + Err(e) => { + log::warn!("[Lua llm_switch] swap failed: {}", e); + Ok(Value::Nil) + } + }, + None => { + log::warn!("[Lua llm_switch] unknown backend name: {}", name); + Ok(Value::Nil) + } + } + })?; + jarvis.set("llm_switch", switch_fn)?; + + // Clear conversation turns (keeps system prompt). Returns true if history was + // initialised, false if LLM is not configured. + let reset_fn = lua.create_function(|_, ()| { + llm::history_clear(); + Ok(true) + })?; + jarvis.set("llm_reset", reset_fn)?; + + // Return the most recent assistant message text, or nil. + let last_fn = lua.create_function(|lua, ()| { + match llm::history_last_assistant() { + Some(text) => { + let s = lua.create_string(text)?; + Ok(Value::String(s)) + } + None => Ok(Value::Nil), + } + })?; + jarvis.set("llm_last_reply", last_fn)?; + + // Snapshot of the full conversation history as an array of + // {role, content} tables. Excludes the system prompt — only the actual + // back-and-forth. Empty array if there's no history yet. + let snapshot_fn = lua.create_function(|lua, ()| { + let arr = lua.create_table()?; + for (i, msg) in llm::history_snapshot().iter().enumerate() { + if msg.role == "system" { + continue; + } + let row = lua.create_table()?; + row.set("role", msg.role.clone())?; + row.set("content", msg.content.clone())?; + arr.set(i, row)?; + } + Ok(arr) + })?; + jarvis.set("llm_history", snapshot_fn)?; + + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/macros.rs b/crates/jarvis-core/src/lua/api/macros.rs new file mode 100644 index 0000000..89d0657 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/macros.rs @@ -0,0 +1,70 @@ +//! Lua bindings for the macro recorder. +//! +//! Usage from Lua: +//! jarvis.macros.start("работа") -- begin recording +//! jarvis.macros.save() -- persist + stop +//! jarvis.macros.cancel() -- abort without saving +//! jarvis.macros.replay("работа") -- play back; returns step count or nil +//! jarvis.macros.list() -- array of {name, steps_count, last_run} +//! jarvis.macros.delete(name) -- removes +//! jarvis.macros.is_recording() -- bool +//! jarvis.macros.recording_name() -- string or nil + +use mlua::{Lua, Table, Value}; + +use crate::macros; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let m = lua.create_table()?; + + m.set("start", lua.create_function(|_, name: String| { + macros::start_recording(&name).map_err(mlua::Error::external) + })?)?; + + m.set("save", lua.create_function(|_, ()| { + macros::save_recording().map_err(mlua::Error::external) + })?)?; + + m.set("cancel", lua.create_function(|_, ()| { + Ok(macros::cancel_recording()) + })?)?; + + m.set("is_recording", lua.create_function(|_, ()| { + Ok(macros::is_recording()) + })?)?; + + m.set("recording_name", lua.create_function(|lua, ()| { + match macros::recording_name() { + Some(n) => Ok(Value::String(lua.create_string(n)?)), + None => Ok(Value::Nil), + } + })?)?; + + m.set("replay", lua.create_function(|_, name: String| { + macros::replay(&name).map_err(mlua::Error::external) + })?)?; + + m.set("delete", lua.create_function(|_, name: String| { + Ok(macros::delete(&name)) + })?)?; + + m.set("list", lua.create_function(|lua, ()| { + let arr = lua.create_table()?; + for (i, mac) in macros::list().iter().enumerate() { + let row = lua.create_table()?; + row.set("name", mac.name.clone())?; + row.set("steps_count", mac.steps.len())?; + row.set("created_at", mac.created_at)?; + let last: Value = match mac.last_run { + Some(ts) => Value::Integer(ts), + None => Value::Nil, + }; + row.set("last_run", last)?; + arr.set(i + 1, row)?; + } + Ok(arr) + })?)?; + + jarvis.set("macros", m)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/memory.rs b/crates/jarvis-core/src/lua/api/memory.rs new file mode 100644 index 0000000..413daf8 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/memory.rs @@ -0,0 +1,77 @@ +//! Lua bindings for the long-term memory store. +//! +//! Usage from Lua scripts: +//! +//! jarvis.memory.remember("любимый чай", "улун") +//! local v = jarvis.memory.recall("любимый чай") -- "улун" or nil +//! for _, hit in ipairs(jarvis.memory.search("чай", 5)) do +//! print(hit.key, hit.value) +//! end +//! jarvis.memory.forget("любимый чай") +//! local all = jarvis.memory.all() -- array of {key, value, ...} + +use mlua::{Lua, Table}; + +use crate::long_term_memory; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let mem = lua.create_table()?; + + let remember = lua.create_function(|_, (k, v): (String, String)| { + long_term_memory::remember(&k, &v) + .map_err(|e| mlua::Error::external(e)) + })?; + mem.set("remember", remember)?; + + let recall = lua.create_function(|_, k: String| { + Ok(long_term_memory::recall(&k)) + })?; + mem.set("recall", recall)?; + + let search = lua.create_function(|lua, (q, n): (String, Option)| { + let hits = long_term_memory::search(&q, n.unwrap_or(5)); + let arr = lua.create_table()?; + for (i, h) in hits.iter().enumerate() { + let row = lua.create_table()?; + row.set("key", h.key.clone())?; + row.set("value", h.value.clone())?; + row.set("created_at", h.created_at)?; + row.set("last_used_at", h.last_used_at)?; + row.set("use_count", h.use_count)?; + arr.set(i + 1, row)?; + } + Ok(arr) + })?; + mem.set("search", search)?; + + let forget = lua.create_function(|_, k: String| { + Ok(long_term_memory::forget(&k)) + })?; + mem.set("forget", forget)?; + + let all = lua.create_function(|lua, ()| { + let recs = long_term_memory::all(); + let arr = lua.create_table()?; + for (i, h) in recs.iter().enumerate() { + let row = lua.create_table()?; + row.set("key", h.key.clone())?; + row.set("value", h.value.clone())?; + row.set("created_at", h.created_at)?; + row.set("last_used_at", h.last_used_at)?; + row.set("use_count", h.use_count)?; + arr.set(i + 1, row)?; + } + Ok(arr) + })?; + mem.set("all", all)?; + + // Wipe ALL memory. Returns count of removed facts. Voice packs should + // only call this behind a deliberate phrase like "точно забудь всё". + let clear_all = lua.create_function(|_, ()| { + Ok(long_term_memory::clear_all()) + })?; + mem.set("clear_all", clear_all)?; + + jarvis.set("memory", mem)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/profile.rs b/crates/jarvis-core/src/lua/api/profile.rs new file mode 100644 index 0000000..6334773 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/profile.rs @@ -0,0 +1,63 @@ +//! Lua bindings for profile switching. +//! +//! Usage from Lua scripts: +//! +//! local p = jarvis.profile.active() -- table {name, icon, description, ...} +//! jarvis.profile.set("work") -- switch to work profile +//! local names = jarvis.profile.list() -- {"default", "work", "game", "sleep", "driving"} +//! if jarvis.profile.allows("games.steam") then ... end + +use mlua::{Lua, Table}; + +use crate::profiles; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let prof = lua.create_table()?; + + let active = lua.create_function(|lua, ()| { + let p = profiles::active(); + let t = lua.create_table()?; + t.set("name", p.name)?; + t.set("description", p.description)?; + t.set("llm_personality", p.llm_personality)?; + t.set("greeting", p.greeting)?; + t.set("icon", p.icon)?; + Ok(t) + })?; + prof.set("active", active)?; + + let active_name = lua.create_function(|_, ()| Ok(profiles::active_name()))?; + prof.set("active_name", active_name)?; + + let set_active = lua.create_function(|lua, name: String| { + match profiles::set_active(&name) { + Ok(p) => { + let t = lua.create_table()?; + t.set("name", p.name)?; + t.set("icon", p.icon)?; + t.set("greeting", p.greeting)?; + Ok(t) + } + Err(e) => Err(mlua::Error::external(e)), + } + })?; + prof.set("set", set_active)?; + + let list = lua.create_function(|lua, ()| { + let names = profiles::list(); + let arr = lua.create_table()?; + for (i, n) in names.iter().enumerate() { + arr.set(i + 1, n.clone())?; + } + Ok(arr) + })?; + prof.set("list", list)?; + + let allows = lua.create_function(|_, cmd_id: String| { + Ok(profiles::active().allows_command(&cmd_id)) + })?; + prof.set("allows", allows)?; + + jarvis.set("profile", prof)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/scheduler.rs b/crates/jarvis-core/src/lua/api/scheduler.rs new file mode 100644 index 0000000..2c08c61 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/scheduler.rs @@ -0,0 +1,161 @@ +//! Lua bindings for the proactive scheduler. +//! +//! Usage: +//! jarvis.scheduler.add({ +//! name = "Daily briefing", +//! schedule = "daily 09:00", +//! action = { type = "speak", text = "Доброе утро. Готов к работе." } +//! }) +//! +//! jarvis.scheduler.add({ +//! name = "Reminder", +//! schedule = "in 5 minutes", +//! action = { type = "speak", text = "Выключи кофеварку." } +//! }) +//! +//! for _, t in ipairs(jarvis.scheduler.list()) do +//! print(t.id, t.name, t.schedule_human) +//! end +//! +//! jarvis.scheduler.remove(id) +//! jarvis.scheduler.clear() +//! +//! Schedule string syntax (see `scheduler::Schedule::parse`): +//! "daily HH:MM" | "at HH:MM" | "every N minutes" | "every N hours" | "in N minutes" | "in N hours" + +use mlua::{Lua, Table, Value}; + +use crate::scheduler::{self, Action, Schedule, ScheduledTask}; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let sched = lua.create_table()?; + + let add_fn = lua.create_function(|_, t: Table| { + let name: String = t.get::>("name")?.unwrap_or_else(|| "task".to_string()); + let schedule_str: String = t.get::("schedule")?; + let id: String = t.get::>("id")?.unwrap_or_default(); + + let action_tbl: Table = t.get::
("action")?; + let action_type: String = action_tbl.get::("type")?; + let action = match action_type.as_str() { + "speak" => Action::Speak { + text: action_tbl.get::("text").unwrap_or_default(), + }, + "lua" => Action::Lua { + script_path: action_tbl.get::("script_path").unwrap_or_default(), + }, + other => return Err(mlua::Error::external(format!("unknown action type: {}", other))), + }; + + let schedule = Schedule::parse(&schedule_str) + .map_err(|e| mlua::Error::external(format!("bad schedule '{}': {}", schedule_str, e)))?; + + let task = ScheduledTask { + id, + name, + schedule, + action, + last_fired: None, + enabled: true, + created_at: 0, + }; + scheduler::add(task).map_err(mlua::Error::external) + })?; + sched.set("add", add_fn)?; + + let remove_fn = lua.create_function(|_, id: String| { + Ok(scheduler::remove(&id)) + })?; + sched.set("remove", remove_fn)?; + + let clear_fn = lua.create_function(|_, ()| { + Ok(scheduler::clear()) + })?; + sched.set("clear", clear_fn)?; + + // Remove all tasks whose name OR speak text matches `query` (substring, + // case-insensitive). Returns count removed. + let remove_by_text_fn = lua.create_function(|_, query: String| { + Ok(scheduler::remove_by_text(&query)) + })?; + sched.set("remove_by_text", remove_by_text_fn)?; + + // Find (don't remove) tasks matching query. Returns array of tasks. + let find_by_text_fn = lua.create_function(|lua, (query, n): (String, Option)| { + let arr = lua.create_table()?; + for (i, t) in scheduler::find_by_text(&query, n.unwrap_or(5)).iter().enumerate() { + arr.set(i + 1, task_to_lua(lua, t)?)?; + } + Ok(arr) + })?; + sched.set("find_by_text", find_by_text_fn)?; + + let list_fn = lua.create_function(|lua, ()| { + let arr = lua.create_table()?; + for (i, t) in scheduler::list().iter().enumerate() { + arr.set(i + 1, task_to_lua(lua, t)?)?; + } + Ok(arr) + })?; + sched.set("list", list_fn)?; + + let count_fn = lua.create_function(|_, ()| { + Ok(scheduler::list().len()) + })?; + sched.set("count", count_fn)?; + + jarvis.set("scheduler", sched)?; + Ok(()) +} + +fn task_to_lua(lua: &Lua, t: &ScheduledTask) -> mlua::Result
{ + let tbl = lua.create_table()?; + tbl.set("id", t.id.clone())?; + tbl.set("name", t.name.clone())?; + tbl.set("enabled", t.enabled)?; + tbl.set("created_at", t.created_at)?; + tbl.set("schedule_human", schedule_human(&t.schedule))?; + + let last_fired: Value = match t.last_fired { + Some(ts) => Value::Integer(ts), + None => Value::Nil, + }; + tbl.set("last_fired", last_fired)?; + + let action_tbl = lua.create_table()?; + match &t.action { + Action::Speak { text } => { + action_tbl.set("type", "speak")?; + action_tbl.set("text", text.clone())?; + } + Action::Lua { script_path } => { + action_tbl.set("type", "lua")?; + action_tbl.set("script_path", script_path.clone())?; + } + } + tbl.set("action", action_tbl)?; + + Ok(tbl) +} + +fn schedule_human(s: &Schedule) -> String { + match s { + Schedule::Daily { hour, minute } => format!("каждый день в {:02}:{:02}", hour, minute), + Schedule::Interval { seconds } => { + if *seconds % 3600 == 0 { + format!("каждые {} часов", seconds / 3600) + } else if *seconds % 60 == 0 { + format!("каждые {} минут", seconds / 60) + } else { + format!("каждые {} секунд", seconds) + } + } + Schedule::Once { at } => { + chrono::DateTime::from_timestamp(*at, 0) + .map(|dt: chrono::DateTime| { + dt.with_timezone(&chrono::Local).format("один раз в %H:%M %d.%m").to_string() + }) + .unwrap_or_else(|| format!("один раз в {}", at)) + } + } +} diff --git a/crates/jarvis-core/src/lua/api/system.rs b/crates/jarvis-core/src/lua/api/system.rs index 2722db8..8f6074f 100644 --- a/crates/jarvis-core/src/lua/api/system.rs +++ b/crates/jarvis-core/src/lua/api/system.rs @@ -78,8 +78,11 @@ pub fn register(lua: &Lua, jarvis: &Table, sandbox: SandboxLevel) -> mlua::Resul #[cfg(target_os = "windows")] { use winrt_notification::{Toast, Duration as ToastDuration}; - - if let Err(e) = Toast::new(Toast::POWERSHELL_APP_ID) + + // Use our registered AUMID so the toast attributes to "J.A.R.V.I.S." + // instead of "PowerShell" in Action Center. Falls back to the + // PowerShell AUMID transparently if registration failed at startup. + if let Err(e) = Toast::new(crate::toast::active_aumid()) .title(&title) .text1(&message) .duration(ToastDuration::Short) diff --git a/crates/jarvis-core/src/lua/api/text.rs b/crates/jarvis-core/src/lua/api/text.rs new file mode 100644 index 0000000..ac6cab3 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/text.rs @@ -0,0 +1,65 @@ +use mlua::{Lua, Table, Value}; + +// Tiny text helpers that every trigger-driven pack reinvents. Centralising +// here removes ~10 lines of identical boilerplate per pack. +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let text = lua.create_table()?; + + // jarvis.text.strip_trigger(phrase, triggers) -> remainder + // + // Lowercases `phrase`, finds the FIRST trigger from `triggers` that + // matches at the start (case-insensitive substring), and returns + // everything after it trimmed. If nothing matches, returns the original + // phrase trimmed. Empty input returns "". + // + // Triggers should be ordered longest-first so "напомни мне через" wins + // over "напомни" — the function does NOT re-sort for the caller. + let strip_fn = lua.create_function(|lua, (phrase, triggers): (String, Table)| { + let lowered = phrase.to_lowercase(); + let mut chosen_end: Option = None; + + for v in triggers.sequence_values::() { + let t = match v { + Ok(Value::String(s)) => s.to_str()?.to_lowercase(), + _ => continue, + }; + if t.is_empty() { + continue; + } + if lowered.starts_with(&t) { + let after = lowered[t.len()..].chars().next(); + if after.map_or(true, |c| !c.is_alphanumeric()) { + chosen_end = Some(t.len()); + break; + } + } + } + + let rest = match chosen_end { + Some(end) => &phrase[end..], + None => phrase.as_str(), + }; + Ok(lua.create_string(rest.trim())?) + })?; + text.set("strip_trigger", strip_fn)?; + + // jarvis.text.contains_any(phrase, needles) -> boolean + // case-insensitive "does any needle appear anywhere in the phrase". + let contains_fn = lua.create_function(|_, (phrase, needles): (String, Table)| { + let lowered = phrase.to_lowercase(); + for v in needles.sequence_values::() { + let n = match v { + Ok(Value::String(s)) => s.to_str()?.to_lowercase(), + _ => continue, + }; + if !n.is_empty() && lowered.contains(&n) { + return Ok(true); + } + } + Ok(false) + })?; + text.set("contains_any", contains_fn)?; + + jarvis.set("text", text)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/tts.rs b/crates/jarvis-core/src/lua/api/tts.rs new file mode 100644 index 0000000..78f730f --- /dev/null +++ b/crates/jarvis-core/src/lua/api/tts.rs @@ -0,0 +1,35 @@ +use mlua::{Lua, Table}; + +use crate::tts::{self, SpeakOpts}; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + // jarvis.speak(text, opts?) + // opts.lang: ISO 2-letter code (ru, en, de, ...). default "ru" + // opts.async: if true, fire-and-forget; if false, block until done. default true + // opts.raw: if true, skip text sanitization (default false) + // + // Uses the active TTS backend (see `crate::tts::backend`). Configured via JARVIS_TTS env var. + let speak_fn = lua.create_function(|_, (text, opts): (String, Option
)| { + let mut speak_opts = SpeakOpts::default(); + + if let Some(t) = opts { + if let Ok(v) = t.get::("lang") { speak_opts.lang = v; } + if let Ok(v) = t.get::("async") { speak_opts.detached = v; } + if let Ok(v) = t.get::("raw") { speak_opts.raw = v; } + } + + tts::speak(&text, &speak_opts); + Ok(()) + })?; + jarvis.set("speak", speak_fn)?; + + // jarvis.tts.backend() — returns name of active backend ("sapi", "piper", "silero") + let tts_table = lua.create_table()?; + let backend_fn = lua.create_function(|_, ()| { + Ok(tts::backend().name().to_string()) + })?; + tts_table.set("backend", backend_fn)?; + jarvis.set("tts", tts_table)?; + + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/api/vision.rs b/crates/jarvis-core/src/lua/api/vision.rs new file mode 100644 index 0000000..ad89e61 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/vision.rs @@ -0,0 +1,189 @@ +//! IMBA-4: Multimodal — screenshot + vision LLM. +//! +//! Lua bindings: +//! jarvis.vision.screenshot(path?) -> path on disk (default temp .png) +//! jarvis.vision.describe(prompt?) -> string description (vision LLM call) +//! +//! Windows-only screenshot via PowerShell System.Drawing. Posts to a vision-capable +//! Groq model (`llama-3.2-11b-vision-preview` by default; override via `GROQ_VISION_MODEL`). +//! +//! Requires HTTP sandbox level (registered behind `allows_http()` in engine.rs). + +use mlua::{Lua, Table}; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let vision = lua.create_table()?; + + let screenshot = lua.create_function(|_, path: Option| { + match take_screenshot(path.as_deref()) { + Ok(p) => Ok(Some(p)), + Err(e) => { + log::warn!("vision.screenshot failed: {}", e); + Ok(None) + } + } + })?; + vision.set("screenshot", screenshot)?; + + let describe = lua.create_function(|_, prompt: Option| { + let user_prompt = prompt.unwrap_or_else(|| { + "Опиши кратко что на этом экране. Если есть ошибки — отметь их.".to_string() + }); + match describe_screen(&user_prompt) { + Ok(text) => Ok(Some(text)), + Err(e) => { + log::warn!("vision.describe failed: {}", e); + Ok(None) + } + } + })?; + vision.set("describe", describe)?; + + jarvis.set("vision", vision)?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn take_screenshot(out_path: Option<&str>) -> Result { + let target = match out_path { + Some(p) => std::path::PathBuf::from(p), + None => { + let f = tempfile::Builder::new() + .prefix("jarvis-screen-") + .suffix(".png") + .tempfile() + .map_err(|e| format!("tempfile: {}", e))?; + let p = f.path().to_path_buf(); + drop(f); + p + } + }; + + let escaped = target.display().to_string().replace('\'', "''"); + let ps = format!( + r#" +Add-Type -AssemblyName System.Windows.Forms; +Add-Type -AssemblyName System.Drawing; +$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; +$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height; +$g = [System.Drawing.Graphics]::FromImage($bmp); +$g.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size); +$bmp.Save('{}', [System.Drawing.Imaging.ImageFormat]::Png); +$g.Dispose(); $bmp.Dispose(); +"#, + escaped + ); + + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| format!("powershell spawn: {}", e))?; + + if !status.success() { + return Err(format!("screenshot powershell exited {}", status)); + } + if !target.is_file() { + return Err("screenshot file not created".to_string()); + } + Ok(target.display().to_string()) +} + +#[cfg(not(target_os = "windows"))] +fn take_screenshot(_out_path: Option<&str>) -> Result { + Err("screenshot not implemented on this platform".into()) +} + +#[cfg(target_os = "windows")] +fn describe_screen(prompt: &str) -> Result { + let path = take_screenshot(None)?; + let b64 = read_as_base64(&path)?; + let _ = std::fs::remove_file(&path); + call_vision_llm(prompt, &b64) +} + +#[cfg(not(target_os = "windows"))] +fn describe_screen(_prompt: &str) -> Result { + Err("vision not implemented on this platform".into()) +} + +#[cfg(target_os = "windows")] +fn read_as_base64(path: &str) -> Result { + // Avoid a new base64 crate dep — use PowerShell [Convert]::ToBase64String. + let escaped = path.replace('\'', "''"); + let ps = format!( + "[Convert]::ToBase64String([IO.File]::ReadAllBytes('{}'))", + escaped + ); + let out = std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .output() + .map_err(|e| format!("base64 powershell: {}", e))?; + if !out.status.success() { + return Err(format!( + "base64 powershell exited {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + )); + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + return Err("base64 output empty".into()); + } + Ok(s) +} + +#[cfg(target_os = "windows")] +fn call_vision_llm(prompt: &str, image_b64: &str) -> Result { + use serde_json::json; + + let token = std::env::var("GROQ_TOKEN") + .map_err(|_| "GROQ_TOKEN not set".to_string())?; + let base = std::env::var("GROQ_BASE_URL") + .unwrap_or_else(|_| "https://api.groq.com/openai/v1".to_string()); + let model = std::env::var("GROQ_VISION_MODEL") + .unwrap_or_else(|_| "llama-3.2-11b-vision-preview".to_string()); + + let body = json!({ + "model": model, + "max_tokens": 400, + "temperature": 0.2, + "messages": [{ + "role": "user", + "content": [ + { "type": "text", "text": prompt }, + { "type": "image_url", + "image_url": { + "url": format!("data:image/png;base64,{}", image_b64) + } + } + ] + }] + }); + + let url = format!("{}/chat/completions", base.trim_end_matches('/')); + let client = reqwest::blocking::Client::new(); + let resp = client + .post(&url) + .bearer_auth(&token) + .json(&body) + .send() + .map_err(|e| format!("HTTP: {}", e))?; + + let status = resp.status(); + let text = resp.text().map_err(|e| format!("read body: {}", e))?; + if !status.is_success() { + return Err(format!("vision API {}: {}", status.as_u16(), text)); + } + + let json: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("parse JSON: {}", e))?; + json.get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .map(|s| s.trim().to_string()) + .ok_or_else(|| format!("no content in response: {}", text)) +} diff --git a/crates/jarvis-core/src/lua/engine.rs b/crates/jarvis-core/src/lua/engine.rs index 71380e1..d41dd9b 100644 --- a/crates/jarvis-core/src/lua/engine.rs +++ b/crates/jarvis-core/src/lua/engine.rs @@ -76,10 +76,21 @@ impl LuaEngine { api::core::register(&self.lua, &jarvis)?; api::audio::register(&self.lua, &jarvis)?; api::context::register(&self.lua, &jarvis, context)?; - + api::tts::register(&self.lua, &jarvis)?; + api::text::register(&self.lua, &jarvis)?; + api::memory::register(&self.lua, &jarvis)?; + api::profile::register(&self.lua, &jarvis)?; + api::scheduler::register(&self.lua, &jarvis)?; + api::cmd::register(&self.lua, &jarvis)?; + api::health::register(&self.lua, &jarvis)?; + api::macros::register(&self.lua, &jarvis)?; + api::banter::register(&self.lua, &jarvis)?; + // sandbox-controlled APIs if self.sandbox.allows_http() { api::http::register(&self.lua, &jarvis)?; + api::llm::register(&self.lua, &jarvis)?; + api::vision::register(&self.lua, &jarvis)?; } if self.sandbox.allows_state() { diff --git a/crates/jarvis-core/src/lua/tests.rs b/crates/jarvis-core/src/lua/tests.rs index f2035ee..c8ef58d 100644 --- a/crates/jarvis-core/src/lua/tests.rs +++ b/crates/jarvis-core/src/lua/tests.rs @@ -93,7 +93,7 @@ mod tests { fn test_sandbox_fs_escape() { let dir = tempdir().unwrap(); let script_path = dir.path().join("test.lua"); - + fs::write(&script_path, r#" local ok, err = pcall(function() jarvis.fs.read("../../../etc/passwd") @@ -103,10 +103,141 @@ mod tests { end return true "#).unwrap(); - + let context = create_test_context(dir.path().to_path_buf()); let result = execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5)); - + assert!(result.is_ok()); } + + /// Drive the shunting-yard evaluator from math.lua via a tiny Lua harness + /// that inlines just the parser. This lets us assert offline arithmetic + /// works without spinning up the LLM or network. We don't include the full + /// 250-line math.lua here — just the eval kernel — because the wider + /// script reaches into the audio/notify modules. + #[test] + fn test_math_shunting_yard_basics() { + let dir = tempdir().unwrap(); + let script_path = dir.path().join("eval.lua"); + + let script = r#" + local function tokenize(s) + local tokens = {} + local i = 1 + while i <= #s do + local c = s:sub(i, i) + if c:match("[%s]") then i = i + 1 + elseif c:match("[%d%.]") then + local j = i + while j <= #s and s:sub(j, j):match("[%d%.]") do j = j + 1 end + table.insert(tokens, { kind = "num", val = tonumber(s:sub(i, j - 1)) }) + i = j + elseif c == "+" or c == "-" or c == "*" or c == "/" or c == "^" then + if (c == "-" or c == "+") and (#tokens == 0 or tokens[#tokens].kind == "op" or tokens[#tokens].kind == "lparen") then + local j = i + 1 + while j <= #s and s:sub(j, j):match("[%s]") do j = j + 1 end + if j <= #s and s:sub(j, j):match("[%d%.]") then + local k = j + while k <= #s and s:sub(k, k):match("[%d%.]") do k = k + 1 end + local v = tonumber(s:sub(j, k - 1)) + if v then + table.insert(tokens, { kind = "num", val = (c == "-" and -v or v) }) + i = k + else return nil end + else return nil end + else + table.insert(tokens, { kind = "op", val = c }); i = i + 1 + end + elseif c == "(" then table.insert(tokens, { kind = "lparen" }); i = i + 1 + elseif c == ")" then table.insert(tokens, { kind = "rparen" }); i = i + 1 + else return nil end + end + return tokens + end + + local function precedence(op) + if op == "+" or op == "-" then return 1 end + if op == "*" or op == "/" then return 2 end + if op == "^" then return 3 end + return 0 + end + + local function eval_op(a, b, op) + if op == "+" then return a + b end + if op == "-" then return a - b end + if op == "*" then return a * b end + if op == "/" then if b == 0 then return nil end; return a / b end + if op == "^" then return a ^ b end + return nil + end + + local function eval(tokens) + if not tokens or #tokens == 0 then return nil end + local values, ops = {}, {} + local function apply() + local op = table.remove(ops) + local b = table.remove(values) + local a = table.remove(values) + if a == nil or b == nil then return false end + local r = eval_op(a, b, op) + if r == nil then return false end + table.insert(values, r) + return true + end + for _, t in ipairs(tokens) do + if t.kind == "num" then table.insert(values, t.val) + elseif t.kind == "op" then + while #ops > 0 and ops[#ops] ~= "(" and precedence(ops[#ops]) >= precedence(t.val) do + if not apply() then return nil end + end + table.insert(ops, t.val) + elseif t.kind == "lparen" then table.insert(ops, "(") + elseif t.kind == "rparen" then + while #ops > 0 and ops[#ops] ~= "(" do + if not apply() then return nil end + end + if ops[#ops] ~= "(" then return nil end + table.remove(ops) + end + end + while #ops > 0 do + if ops[#ops] == "(" then return nil end + if not apply() then return nil end + end + if #values ~= 1 then return nil end + return values[1] + end + + local cases = { + { "2 + 2", 4 }, + { "10 - 4", 6 }, + { "3 * 5", 15 }, + { "20 / 4", 5 }, + { "2 ^ 10", 1024 }, + { "1 + 2 * 3", 7 }, + { "(1 + 2) * 3", 9 }, + { "-5 + 10", 5 }, + { "100 / 0", nil }, + { "abc", nil }, + } + for _, c in ipairs(cases) do + local got = eval(tokenize(c[1])) + if got ~= c[2] then + error(string.format("case %q: expected %s, got %s", + c[1], tostring(c[2]), tostring(got))) + end + end + return true + "#; + + fs::write(&script_path, script).unwrap(); + let context = create_test_context(dir.path().to_path_buf()); + let result = execute( + &script_path, + context, + SandboxLevel::Minimal, + Duration::from_secs(5), + ); + assert!(result.is_ok(), "shunting yard test failed: {:?}", result); + } } \ No newline at end of file diff --git a/crates/jarvis-core/src/macros.rs b/crates/jarvis-core/src/macros.rs new file mode 100644 index 0000000..140171b --- /dev/null +++ b/crates/jarvis-core/src/macros.rs @@ -0,0 +1,334 @@ +//! IMBA-7: Voice-command macros — record a sequence of commands once, +//! replay them later with one phrase. +//! +//! Simpler than AHK-style keyboard hooks: we record what the user SAID, then +//! replay each phrase through the normal dispatch path. Works for anything +//! that's already a known command (volume, media keys, profile switches, ...). +//! +//! Lifecycle: +//! +//! "Запиши макрос работа" → start_recording("работа") +//! "Открой браузер" → recorder buffers "открой браузер" +//! "Запусти спотифай" → recorder buffers +//! "Режим работа" → recorder buffers +//! "Сохрани макрос" → save() → persists to disk +//! +//! "Запусти макрос работа" → replay("работа") → fires each phrase in order, +//! with a small inter-step delay +//! +//! Storage: JSON at `/macros.json`. + +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::APP_CONFIG_DIR; + +const FILE_NAME: &str = "macros.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Macro { + pub name: String, + pub steps: Vec, + #[serde(default)] + pub created_at: i64, + #[serde(default)] + pub last_run: Option, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +struct Store { + #[serde(default)] + macros: BTreeMap, +} + +#[derive(Debug)] +struct RecordingState { + name: String, + steps: Vec, +} + +struct State { + path: PathBuf, + store: RwLock, + recording: RwLock>, +} + +static STATE: OnceCell = OnceCell::new(); + +pub fn init() -> Result<(), String> { + if STATE.get().is_some() { return Ok(()); } + let dir = APP_CONFIG_DIR.get() + .ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?; + let path = dir.join(FILE_NAME); + let store = load_or_empty(&path); + info!("Macros loaded: {} stored at {}", store.macros.len(), path.display()); + STATE.set(State { path, store: RwLock::new(store), recording: RwLock::new(None) }) + .map_err(|_| "macros already initialised".to_string())?; + Ok(()) +} + +fn load_or_empty(path: &PathBuf) -> Store { + if !path.is_file() { return Store::default(); } + match std::fs::read_to_string(path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| { + warn!("Corrupt macros file {}: {}", path.display(), e); + Store::default() + }), + Err(e) => { + warn!("Cannot read {}: {}", path.display(), e); + Store::default() + } + } +} + +fn persist(state: &State) { + let store = state.store.read(); + let tmp = state.path.with_extension("json.tmp"); + let json = match serde_json::to_string_pretty(&*store) { + Ok(s) => s, + Err(e) => { warn!("Macros serialize failed: {}", e); return; } + }; + if let Err(e) = std::fs::write(&tmp, json) { + warn!("Macros write failed: {}", e); return; + } + if let Err(e) = std::fs::rename(&tmp, &state.path) { + warn!("Macros rename failed: {}", e); + } +} + +fn now_secs() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn normalize_name(n: &str) -> String { + n.trim().to_lowercase() +} + +/// Begin recording a new macro. Replaces any in-progress recording. +pub fn start_recording(name: &str) -> Result<(), String> { + let state = STATE.get().ok_or_else(|| "macros not init".to_string())?; + let nname = normalize_name(name); + if nname.is_empty() { return Err("empty macro name".into()); } + *state.recording.write() = Some(RecordingState { + name: nname.clone(), + steps: Vec::new(), + }); + info!("Macros: recording started for '{}'", nname); + Ok(()) +} + +/// Buffer a phrase into the active recording (if any). Called by the command +/// dispatch hook after a command runs successfully. +pub fn record_step(phrase: &str) { + let Some(state) = STATE.get() else { return; }; + let mut rec = state.recording.write(); + if let Some(r) = rec.as_mut() { + let p = phrase.trim(); + if p.is_empty() { return; } + // Don't record macro-control commands themselves (would be infinite recursion). + if is_macro_control(p) { return; } + r.steps.push(p.to_string()); + debug!("Macros: recorded step '{}' for '{}'", p, r.name); + } +} + +fn is_macro_control(phrase: &str) -> bool { + let p = phrase.to_lowercase(); + p.contains("запиши макрос") || p.contains("сохрани макрос") + || p.contains("отмени макрос") || p.contains("прекрати макрос") + || p.contains("отмени запись") + || p.starts_with("запусти макрос") || p.starts_with("воспроизведи макрос") + || p.starts_with("record macro") || p.starts_with("save macro") + || p.starts_with("play macro") +} + +/// Returns true if a recording is currently active. +pub fn is_recording() -> bool { + STATE.get().and_then(|s| s.recording.read().as_ref().map(|_| ())).is_some() +} + +/// Name of the macro currently being recorded, if any. +pub fn recording_name() -> Option { + STATE.get().and_then(|s| s.recording.read().as_ref().map(|r| r.name.clone())) +} + +/// Stop recording and save the macro. Returns the number of steps captured. +pub fn save_recording() -> Result { + let state = STATE.get().ok_or_else(|| "macros not init".to_string())?; + let rec = state.recording.write().take() + .ok_or_else(|| "не было активной записи".to_string())?; + + if rec.steps.is_empty() { + return Err("макрос пустой".into()); + } + + let count = rec.steps.len(); + let m = Macro { + name: rec.name.clone(), + steps: rec.steps, + created_at: now_secs(), + last_run: None, + }; + state.store.write().macros.insert(rec.name.clone(), m); + persist(state); + info!("Macros: saved '{}' ({} steps)", rec.name, count); + Ok(count) +} + +/// Cancel the active recording without saving. +pub fn cancel_recording() -> bool { + let Some(state) = STATE.get() else { return false; }; + state.recording.write().take().is_some() +} + +pub fn list() -> Vec { + let Some(state) = STATE.get() else { return Vec::new(); }; + state.store.read().macros.values().cloned().collect() +} + +pub fn get(name: &str) -> Option { + let state = STATE.get()?; + state.store.read().macros.get(&normalize_name(name)).cloned() +} + +pub fn delete(name: &str) -> bool { + let Some(state) = STATE.get() else { return false; }; + let removed = state.store.write().macros.remove(&normalize_name(name)).is_some(); + if removed { persist(state); } + removed +} + +/// Mark a macro as run (updates last_run timestamp). +pub fn mark_run(name: &str) { + let Some(state) = STATE.get() else { return; }; + let nname = normalize_name(name); + { + let mut store = state.store.write(); + if let Some(m) = store.macros.get_mut(&nname) { + m.last_run = Some(now_secs()); + } + } + persist(state); +} + +/// Replay callback — jarvis-app registers a function that fires a text command. +/// Macros module owns the timing of replays. +pub type ReplayCallback = Box; + +static REPLAY_CB: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + +pub fn set_replay_callback(cb: ReplayCallback) { + let _ = REPLAY_CB.set(cb); +} + +const STEP_DELAY_MS: u64 = 800; + +/// Replay a stored macro. Each step is sent to the registered callback (which +/// the app wires to its text-command channel). Inter-step delay gives the +/// dispatcher time to process before the next step arrives. Returns step count. +pub fn replay(name: &str) -> Result { + let m = get(name).ok_or_else(|| format!("Макрос '{}' не найден.", name))?; + let cb = REPLAY_CB.get().ok_or_else(|| { + "Replay callback not registered (jarvis-app didn't wire it).".to_string() + })?; + + let steps_count = m.steps.len(); + info!("Macros: replay '{}' ({} steps)", m.name, steps_count); + let _ = cb; // sanity-checked existence; thread re-reads REPLAY_CB inside. + + // Spawn so the caller (likely a Lua script) returns immediately. + let name_owned = m.name.clone(); + let steps = m.steps.clone(); + std::thread::Builder::new() + .name(format!("macro-replay-{}", name_owned)) + .spawn(move || { + let cb = match REPLAY_CB.get() { + Some(c) => c, + None => { warn!("Macros: callback vanished mid-replay"); return; } + }; + for (i, step) in steps.iter().enumerate() { + info!("Macros: replay step {}/{}: {}", i + 1, steps.len(), step); + cb(step); + if i + 1 < steps.len() { + std::thread::sleep(std::time::Duration::from_millis(STEP_DELAY_MS)); + } + } + mark_run(&name_owned); + }) + .ok(); + + Ok(steps_count) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_macro_control_catches_record_phrase() { + assert!(is_macro_control("Запиши макрос работа")); + assert!(is_macro_control("сохрани макрос")); + assert!(is_macro_control("запусти макрос работа")); + assert!(!is_macro_control("открой браузер")); + assert!(!is_macro_control("установи громкость 50")); + } + + #[test] + fn normalize_name_lowercases_trims() { + assert_eq!(normalize_name(" Работа "), "работа"); + assert_eq!(normalize_name("GameMode"), "gamemode"); + } + + #[test] + fn macro_serde_round_trip() { + let m = Macro { + name: "test".into(), + steps: vec!["a".into(), "b".into()], + created_at: 100, + last_run: Some(200), + }; + let json = serde_json::to_string(&m).unwrap(); + let back: Macro = serde_json::from_str(&json).unwrap(); + assert_eq!(back.name, "test"); + assert_eq!(back.steps.len(), 2); + assert_eq!(back.last_run, Some(200)); + } + + #[test] + fn store_serde_round_trip() { + let mut store = Store::default(); + store.macros.insert("a".into(), Macro { + name: "a".into(), + steps: vec!["x".into()], + created_at: 1, + last_run: None, + }); + store.macros.insert("b".into(), Macro { + name: "b".into(), + steps: vec!["y".into(), "z".into()], + created_at: 2, + last_run: Some(3), + }); + let json = serde_json::to_string(&store).unwrap(); + let back: Store = serde_json::from_str(&json).unwrap(); + assert_eq!(back.macros.len(), 2); + assert_eq!(back.macros.get("b").unwrap().steps.len(), 2); + } + + #[test] + fn is_macro_control_handles_case() { + assert!(is_macro_control("ЗАПИШИ МАКРОС работа")); + assert!(is_macro_control("Сохрани макрос пожалуйста")); + assert!(is_macro_control("запусти макрос игра")); + // Noun form "запись макроса" intentionally does NOT match — we only + // filter the actual control verb-phrases. + assert!(!is_macro_control("запись макроса")); + } +} diff --git a/crates/jarvis-core/src/plugins.rs b/crates/jarvis-core/src/plugins.rs new file mode 100644 index 0000000..3566a9b --- /dev/null +++ b/crates/jarvis-core/src/plugins.rs @@ -0,0 +1,379 @@ +//! Plugin system — user-installed command packs that live outside the read-only +//! `resources/commands/` tree. +//! +//! Why: shipping new voice packs as built-ins requires a rebuild + release. End +//! users (and external authors) want to drop a folder into a writable location +//! and have Jarvis pick it up. That's exactly what this module enables. +//! +//! Layout (per pack): +//! +//! ```text +//! /plugins/ +//! / +//! command.toml (required — same schema as built-in packs) +//!
-

© {currentYear}. {t('footer-author')}: {authorName}

+ {#if backends} +

+ + TTS {chipLabel(backends.tts)} + + + LLM {chipLabel(backends.llm)} + + {#if backends.profile && backends.profile !== "default"} + + Profile {backends.profile} + + {/if} +

+ {/if} +

+ © {currentYear} J.A.R.V.I.S. + Rust +

@@ -69,6 +139,48 @@ line-height: 1.7em; margin-top: 15px; + .edition { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 4px; + background: #b7411a; + color: #fff; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + vertical-align: middle; + } + + .backends { + margin-bottom: 8px; + + .chip { + display: inline-block; + margin: 0 3px; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.2px; + color: #cfd2d5; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + + small { + color: #6c6e71; + margin-right: 3px; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &.chip-tts { border-color: rgba(82, 254, 254, 0.25); } + &.chip-llm { border-color: rgba(138, 200, 50, 0.25); } + &.chip-profile { border-color: rgba(255, 168, 60, 0.25); } + } + } + p { margin: 0; padding: 0; @@ -79,15 +191,17 @@ &.last { margin-top: -5px; + color: #8a8d90; + font-size: 11px; } } } a { - color: #555759!important; + color: #555759 !important; text-decoration: none; transition: 0.3s; - + & > span { color: #185876; border-bottom: 1px solid #185876; @@ -101,7 +215,7 @@ } &:hover { - color: #777a7d!important; + color: #777a7d !important; & > span { color: #2A9CD0; @@ -111,30 +225,6 @@ opacity: 1; } } - - &.telegram-link { - color: #185876; - display: inline-block; - - &:hover { - color: #2A9CD0; - // background: url(/media/images/bg/bg24.gif); - // background-repeat: no-repeat; - // background-size: contain; - } - } - - &.special-link { - color: #941d92; - display: inline-block; - - &:hover { - color: #FF07FC; - background: url(/media/images/bg/bg24.gif); - background-repeat: no-repeat; - background-size: contain; - } - } } } diff --git a/frontend/src/components/Header.svelte b/frontend/src/components/Header.svelte index ef778aa..12762dc 100644 --- a/frontend/src/components/Header.svelte +++ b/frontend/src/components/Header.svelte @@ -1,9 +1,9 @@ - + \ No newline at end of file + .reload-btn { + background: transparent; + border: 1px solid #3a3d40; + color: #c8cacc; + border-radius: 8px; + height: 34px; + width: 34px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: 0.2s; + &:hover { + border-color: #5b8def; + color: #5b8def; + } + } + .meta { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px 12px; + font-size: 12px; + color: #8a8d90; + .count { + background: #5b8def; + color: #fff; + padding: 1px 8px; + border-radius: 10px; + font-weight: 600; + } + .dot { color: #46484b; } + } + .state { + padding: 30px 12px; + text-align: center; + color: #8a8d90; + font-size: 13px; + &.error { + color: #c93a3a; + font-family: monospace; + } + } + .grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + padding: 0 10px 10px; + } + .card { + background: #1c1d20; + border: 1px solid #2a2c2f; + border-radius: 8px; + padding: 10px 12px; + transition: border-color 0.15s; + &:hover { + border-color: #3a3d40; + } + } + .card-head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + flex-wrap: wrap; + } + .cmd-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: #e0e2e4; + font-weight: 600; + margin-right: auto; + } + .card-desc { + font-size: 12px; + color: #a0a3a6; + margin-bottom: 6px; + } + .phrases { + list-style: none; + margin: 0; + padding: 0; + font-size: 12px; + color: #c8cacc; + line-height: 1.6em; + li { + display: inline-block; + background: #25272a; + border-radius: 4px; + padding: 1px 7px; + margin: 2px 4px 2px 0; + } + } + diff --git a/frontend/src/routes/history/index.svelte b/frontend/src/routes/history/index.svelte new file mode 100644 index 0000000..4fd9cbc --- /dev/null +++ b/frontend/src/routes/history/index.svelte @@ -0,0 +1,313 @@ + + + + +

История распознаваний

+ + Каждая фраза, которую Jarvis услышал и обработал. Зелёный — команда + нашлась и выполнилась, оранжевый — не понял, синий — обработал через LLM, + красный — была ошибка. Полезно понимать, что именно надо переформулировать + или дотренировать. + + + + +
+ ✓ {matched} +   + ✗ {misses} +   + всего {entries.length} +
+ + + +
+ +   + +   + +
+ + + +{#if error} + { error = "" }} + > + {error} + + +{/if} + +{#if loading} + +{:else if filtered.length === 0} + + {entries.length === 0 + ? "История пуста. Скажи что-нибудь — появится здесь." + : "Под фильтр ничего не подходит."} + +{:else} +
+ {#each filtered as e} +
+
+ «{e.phrase}» + + {kindLabel(e)} + +
+ + + + {#if e.error_message} +
{e.error_message}
+ {/if} +
+ {/each} +
+{/if} + + + + + + +
+ + diff --git a/frontend/src/routes/macros/index.svelte b/frontend/src/routes/macros/index.svelte new file mode 100644 index 0000000..337ee99 --- /dev/null +++ b/frontend/src/routes/macros/index.svelte @@ -0,0 +1,306 @@ + + + + +

Макросы

+ + Запиши последовательность голосовых команд, потом запускай её одной фразой. + + + + +{#if isRecording} + + + Записывается макрос {recordingName ?? "—"}. + Произнесите команды, потом нажмите «Сохранить» или скажите «Сохрани макрос». + + + +   + + + +{:else} +
+ + +
+ +{/if} + +{#if error} + { error = "" }} + > + {error} + + +{/if} + +{#if loading} + +{:else if macros.length === 0} + + Макросов пока нет. Создайте первый: введите имя, нажмите «Начать запись», + произнесите команды, нажмите «Сохранить». + +{:else} +
+ {#each macros as m} +
+
+ {m.name} + + {m.steps_count} шагов + +
+
+ {#each m.steps.slice(0, 5) as step} +
• {step}
+ {/each} + {#if m.steps.length > 5} +
+ ещё {m.steps.length - 5}…
+ {/if} +
+
+ Создан: {fmtDate(m.created_at)} + Последний запуск: {fmtDate(m.last_run)} +
+
+ +   + +
+
+ {/each} +
+{/if} + + + + + + +
+ + diff --git a/frontend/src/routes/memory/index.svelte b/frontend/src/routes/memory/index.svelte new file mode 100644 index 0000000..3ad7904 --- /dev/null +++ b/frontend/src/routes/memory/index.svelte @@ -0,0 +1,249 @@ + + + + +

Память

+ + Долговременные факты о пользователе. LLM получает их в системном промпте автоматически, + когда фразой пересекаются. Хранятся в long_term_memory.json. + + + + +
+
+ + + +
+
+ + + + + + + +{#if error} + { error = "" }} + > + {error} + + +{/if} + +{#if loading} + +{:else if facts.length === 0} + + Память пустая. Добавь факт сверху или скажи Jarvis-у "запомни что я люблю чай улун". + +{:else if visible.length === 0} + + По фильтру ничего не найдено. Всего фактов: {facts.length}. + +{:else} +
+ {#each visible as fact} +
+
+ {fact.key} +
+ + {fact.use_count}× использований + +
+
+
+ {fact.value} +
+
+ Создан: {fmtDate(fact.created_at)} + Последний доступ: {fmtDate(fact.last_used_at)} +
+
+ +
+
+ {/each} +
+{/if} + + + + + + +
+ + diff --git a/frontend/src/routes/plugins/index.svelte b/frontend/src/routes/plugins/index.svelte new file mode 100644 index 0000000..7ae8161 --- /dev/null +++ b/frontend/src/routes/plugins/index.svelte @@ -0,0 +1,237 @@ + + + + +

Плагины

+ + Дополнительные voice-команды, которые лежат рядом с конфигом + (%APPDATA%\com.priler.jarvis\plugins\). Каждый плагин — это + папка с command.toml и Lua-скриптами. + Меняется только после перезапуска. + + + + +
+ +   + +
+ + + +{#if info} + { info = "" }}> + {info} + + +{/if} + +{#if error} + { error = "" }} + > + {error} + + +{/if} + +{#if loading} + +{:else if plugins.length === 0} + + Плагинов пока нет. Скопируйте папку плагина в указанный каталог + и нажмите «Обновить список». + +{:else} +
+ {#each plugins as p} +
+
+ {p.name} + {#if p.error} + ошибка + {:else} + + {p.command_count} команд + + {/if} +
+
{p.path}
+ {#if p.error} +
{p.error}
+ {/if} +
+ toggle(p)} + /> + + {p.enabled ? "Включён" : "Выключен"} + +
+
+ {/each} +
+{/if} + + + + + + +
+ + diff --git a/frontend/src/routes/scheduler/index.svelte b/frontend/src/routes/scheduler/index.svelte new file mode 100644 index 0000000..8fee2cd --- /dev/null +++ b/frontend/src/routes/scheduler/index.svelte @@ -0,0 +1,238 @@ + + + + +

Расписание

+ + Запланированные задачи: напоминания, ежедневные брифинги, привычки. + Тик каждые 30 секунд. + + + + +{#if error} + { error = "" }} + > + {error} + + +{/if} + +{#if loading} + +{:else if tasks.length === 0} + + Расписание пустое. Добавь задачу голосом: + «напомни через 5 минут выключить кофеварку», «каждые 2 часа напоминай пить воду», + «каждый день в 9 утра делай брифинг», «напоминай отдыхать глазам». + +{:else} +
+ {#each tasks as task} +
+
+
+ {task.name} + + {task.action_kind} + +
+ + {task.schedule_human} + +
+ + {#if task.action_text} +
+ {task.action_text} +
+ {/if} + +
+ ID: {task.id} + Создано: {fmtDate(task.created_at)} + Последний запуск: {fmtDate(task.last_fired)} +
+ +
+ +
+
+ {/each} +
+ + + +{/if} + + + + + + +
+ + diff --git a/frontend/src/routes/settings/index.svelte b/frontend/src/routes/settings/index.svelte index ab8af45..f3b77fa 100644 --- a/frontend/src/routes/settings/index.svelte +++ b/frontend/src/routes/settings/index.svelte @@ -5,7 +5,11 @@ import { setTimeout } from "worker-timers" import { showInExplorer } from "@/functions" - import { appInfo, assistantVoice, translations, translate } from "@/stores" + import { + appInfo, assistantVoice, translations, translate, + switchDaemonLlm, reloadDaemonLlm, switchDaemonTts, + daemonHealth, queryDaemonHealth, + } from "@/stores" import HDivider from "@/components/elements/HDivider.svelte" import Footer from "@/components/Footer.svelte" @@ -20,7 +24,8 @@ Input, InputWrapper, NativeSelect, - Switch + Switch, + Badge, } from "@svelteuidev/core" import { @@ -30,7 +35,8 @@ Code, Gear, QuestionMarkCircled, - CrossCircled + CrossCircled, + ChatBubble, } from "radix-icons-svelte" $: t = (key: string) => translate($translations, key) @@ -85,15 +91,117 @@ let apiKeyPicovoice = "" let apiKeyOpenai = "" + // ── AI backends (new tab) ───────────────────────────────────────────── + interface ActiveBackends { + tts: string + llm: string + llm_model: string | null + profile: string + } + + let activeBackends: ActiveBackends | null = null + let selectedLlmBackend = "" // "" = auto, "groq", "ollama" + let selectedTtsBackend = "" // "" = auto, "sapi", "piper", "silero" + let backendSwapBusy = false + let backendSwapError = "" + + async function refreshActiveBackends() { + try { + activeBackends = await invoke("get_active_backends") + } catch (err) { + console.error("Failed to read active backends:", err) + } + } + + async function applyLlmBackend(target: string) { + backendSwapError = "" + if (target === "") { + try { + await invoke("db_write", { key: "llm_backend", val: "" }) + reloadDaemonLlm() // tell daemon to re-read DB (now auto) + } catch (err) { + backendSwapError = String(err) + } + await refreshActiveBackends() + return + } + backendSwapBusy = true + try { + // 1. Swap on GUI process (persists to DB). + await invoke("set_llm_backend", { name: target }) + // 2. Tell daemon to swap too, so the running listener uses the + // new backend immediately. Returns false if WS disconnected + // so we can warn the user — otherwise they'd think the swap + // silently failed. + const daemonAware = switchDaemonLlm(target as "groq" | "ollama") + if (!daemonAware) { + backendSwapError = + "Демон не подключён — LLM в нём переключится при следующем запуске." + } + await refreshActiveBackends() + queryDaemonHealth() + } catch (err) { + backendSwapError = String(err) + } finally { + backendSwapBusy = false + } + } + + interface TtsSwapResult { + requested: string + applied: string + fell_back: boolean + note: string | null + } + + let ttsSwapInfo: TtsSwapResult | null = null + + async function applyTtsBackend(target: string) { + backendSwapError = "" + ttsSwapInfo = null + try { + // 1. GUI Tauri command — persists to DB + hot-swaps GUI's TTS. + // Returns the actual installed backend (may differ from + // requested if Piper/Silero unavailable → fell back to SAPI). + const res = await invoke("set_tts_backend", { name: target }) + ttsSwapInfo = res + // 2. Tell the running daemon (separate process) to switch too. + // sendAction returns false if the WS isn't connected — + // surface that so the user knows the daemon won't change. + const daemonAware = switchDaemonTts(target || "auto") + if (!daemonAware) { + backendSwapError = + "Демон (jarvis-app) не подключён. Изменение применится при следующем запуске. " + + "Запусти Jarvis с главного экрана и повтори, если нужно немедленно." + } + // 3. Refresh the local GUI snapshot AND re-poll daemon health + // so the "Активный движок" chip updates within ~100ms. + // Without the daemon re-poll, the chip keeps showing the + // OLD daemon-side TTS until the next 5s footer poll. + await refreshActiveBackends() + queryDaemonHealth() + } catch (err) { + backendSwapError = String(err) + } + } + + async function resetLlmContext() { + try { + await invoke("llm_reset_context") + } catch (err) { + console.error(err) + } + } + // subscribe to stores assistantVoice.subscribe(value => { voiceVal = value }) - let feedbackLink = "" + let issuesLink = "" let logFilePath = "" appInfo.subscribe(info => { - feedbackLink = info.feedbackLink + issuesLink = info.issuesLink logFilePath = info.logFilePath }) @@ -166,7 +274,6 @@ const languageNames: Record = { us: 'English', ru: 'Русский', - uk: 'Українська', de: 'German', fr: 'French', es: 'Spanish', @@ -215,6 +322,19 @@ gainNormalizerEnabled = gainNormalizer === "true" apiKeyPicovoice = pico apiKeyOpenai = openai + + // load AI backend prefs + try { + const [llm_pref, tts_pref] = await Promise.all([ + invoke("db_read", { key: "llm_backend" }), + invoke("db_read", { key: "tts_backend" }), + ]) + selectedLlmBackend = llm_pref ?? "" + selectedTtsBackend = tts_pref ?? "" + } catch (err) { + console.error("Failed to load backend prefs:", err) + } + await refreshActiveBackends() } catch (err) { console.error("failed to load settings:", err) } @@ -230,7 +350,7 @@ withCloseButton={false} > {t('settings-beta-desc')}
- {t('settings-beta-feedback')} {t('settings-beta-bot')}. + {t('settings-beta-feedback')} {t('settings-beta-bot')}. + + + + + + + • Ollama: установи с ollama.com, + выполни ollama pull qwen2.5:3b.
+ • Piper: запусти pwsh tools/piper/install.ps1 чтобы скачать голос.
+ • Голосом: «переключись на локальный» / «переключись на облако», + «какой у тебя мозг» — спрашивает статус.
+ • Сброс/повтор: «сбрось контекст» / «повтори последнее». +
+
+ + + + + + + + + + +   + + + {#if status.collected >= 5} + +
+ Порог детектора: {threshold.toFixed(2)} + +
+ + Ниже — больше срабатываний (и ложных). Выше — пропусков больше. + + + + {/if} + +{:else} +
+ + + + + +
+{/if} + + + +

Обученные модели

+{#if models.length === 0} + Моделей пока нет — обучите первую выше. +{:else} +
+ {#each models as m} +
+
+ {m.name} + {fmtSize(m.size_bytes)} +
+
{m.path}
+ обновлено: {fmtDate(m.modified)} + + +
+ {/each} +
+{/if} + + + + + + +
+ + diff --git a/frontend/src/stores.ts b/frontend/src/stores.ts index 51603ed..d67c404 100644 --- a/frontend/src/stores.ts +++ b/frontend/src/stores.ts @@ -8,6 +8,7 @@ export { lastRecognizedText, lastExecutedCommand, lastError, + daemonHealth, connectIpc, enableIpc, disableIpc, @@ -16,9 +17,15 @@ export { sendIpcMessage, sendTextCommand, stopJarvisApp, - reloadCommands + reloadCommands, + switchDaemonLlm, + reloadDaemonLlm, + switchDaemonTts, + queryDaemonHealth, } from "./lib/ipc" +export type { DaemonHealth } from "./lib/ipc" + // re-export i18n export { translations, @@ -39,12 +46,21 @@ export const jarvisCpuUsage = writable(0) export const assistantVoice = writable("") // ### APP INFO +// Hardcoded fallbacks so the UI keeps working when the Rust side has not +// been rebuilt yet (the new get_upstream_link / get_issues_link / get_python_fork_link +// Tauri commands only exist after the next cargo build). +const BRANDING_FALLBACK = { + repositoryLink: "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust", + upstreamLink: "https://github.com/Priler/jarvis", + issuesLink: "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-rust/issues", + pythonForkLink: "https://github.com/DmitryBykov-ISPO/J.A.R.V.I.S-py", +} + export const appInfo = writable({ - tgOfficialLink: "", - feedbackLink: "", - repositoryLink: "", - boostySupportLink: "", - patreonSupportLink: "", + repositoryLink: BRANDING_FALLBACK.repositoryLink, + upstreamLink: BRANDING_FALLBACK.upstreamLink, + issuesLink: BRANDING_FALLBACK.issuesLink, + pythonForkLink: BRANDING_FALLBACK.pythonForkLink, logFilePath: "" }) @@ -59,27 +75,38 @@ export async function loadVoiceSetting() { } export async function loadAppInfo() { - try { - const [tg, feedback, repo, boosty, patreon, logPath] = await Promise.all([ - invoke("get_tg_official_link"), - invoke("get_feedback_link"), - invoke("get_repository_link"), - invoke("get_boosty_link"), - invoke("get_patreon_link"), - invoke("get_log_file_path") - ]) - - appInfo.set({ - tgOfficialLink: tg, - feedbackLink: feedback, - repositoryLink: repo, - boostySupportLink: boosty, - patreonSupportLink: patreon, - logFilePath: logPath - }) - } catch (err) { - console.error("failed to load app info:", err) + const fetchOr = async (name: string, fallback: string): Promise => { + try { + const v = await invoke(name) + return v && v !== "error" ? v : fallback + } catch { + return fallback + } } + + // The old binary's get_repository_link returns Priler's upstream — override + // so we never display the upstream repo as if it were the current fork. + const fetchRepoOr = async (fallback: string): Promise => { + const v = await fetchOr("get_repository_link", fallback) + if (/priler\/jarvis/i.test(v)) return fallback + return v + } + + const [repo, upstream, issues, pythonFork, logPath] = await Promise.all([ + fetchRepoOr(BRANDING_FALLBACK.repositoryLink), + fetchOr("get_upstream_link", BRANDING_FALLBACK.upstreamLink), + fetchOr("get_issues_link", BRANDING_FALLBACK.issuesLink), + fetchOr("get_python_fork_link", BRANDING_FALLBACK.pythonForkLink), + fetchOr("get_log_file_path", "") + ]) + + appInfo.set({ + repositoryLink: repo, + upstreamLink: upstream, + issuesLink: issues, + pythonForkLink: pythonFork, + logFilePath: logPath + }) } export async function updateJarvisStats() { @@ -98,7 +125,7 @@ let statsInterval: ReturnType | null = null export function startStatsPolling(intervalMs = 5000) { if (statsInterval) return // already running - + updateJarvisStats() statsInterval = setInterval(updateJarvisStats, intervalMs) } @@ -108,4 +135,4 @@ export function stopStatsPolling() { clearInterval(statsInterval) statsInterval = null } -} \ No newline at end of file +} diff --git a/make_ico.py b/make_ico.py new file mode 100644 index 0000000..50e382d --- /dev/null +++ b/make_ico.py @@ -0,0 +1,8 @@ +from PIL import Image +src = r"C:\Jarvis\rust\resources\icons\icon.png" +dst = r"C:\Jarvis\rust\resources\icons\icon.ico" + +img = Image.open(src).convert("RGBA") +sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] +img.save(dst, format="ICO", sizes=sizes) +print(f"wrote {dst} from {src} ({img.size})") diff --git a/resources/commands/apps/command.toml b/resources/commands/apps/command.toml new file mode 100644 index 0000000..b1e95ce --- /dev/null +++ b/resources/commands/apps/command.toml @@ -0,0 +1,191 @@ +[[commands]] +id = "open_browser" +type = "lua" +script = "open_browser.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "открой браузер", + "запусти браузер", + "открой хром", + "запусти хром", + "включи браузер", + "браузер открой", + "нужен браузер", +] +en = [ + "open browser", + "launch browser", + "open chrome", + "launch chrome", + "start browser", + "i need browser", +] + + +[[commands]] +id = "open_notepad" +type = "lua" +script = "open_notepad.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "открой блокнот", + "запусти блокнот", + "открой нотепад", +] +en = [ + "open notepad", + "launch notepad", +] + + +[[commands]] +id = "open_calculator" +type = "lua" +script = "open_calculator.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "открой калькулятор", + "запусти калькулятор", + "нужен калькулятор", +] +en = [ + "open calculator", + "launch calculator", + "calculator please", +] + + +[[commands]] +id = "open_explorer" +type = "lua" +script = "open_explorer.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "открой проводник", + "открой папку", + "запусти проводник", + "открой файлы", +] +en = [ + "open explorer", + "open file explorer", + "open files", + "open folder", +] + + +[[commands]] +id = "open_terminal" +type = "lua" +script = "open_terminal.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "открой терминал", + "запусти терминал", + "открой консоль", + "запусти консоль", + "открой повершелл", +] +en = [ + "open terminal", + "open powershell", + "open console", + "launch terminal", +] + + +[[commands]] +id = "open_settings" +type = "lua" +script = "open_settings.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "открой настройки", + "открой параметры", + "запусти настройки", + "открой настройки виндоус", +] +en = [ + "open settings", + "launch settings", + "windows settings", +] + + +[[commands]] +id = "open_task_manager" +type = "lua" +script = "open_task_manager.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "открой диспетчер задач", + "запусти диспетчер задач", + "диспетчер задач", +] +en = [ + "open task manager", + "launch task manager", + "task manager", +] + + +[[commands]] +id = "lock_screen" +type = "lua" +script = "lock_screen.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "заблокируй компьютер", + "заблокируй экран", + "блокировка экрана", + "залочь", +] +en = [ + "lock screen", + "lock the computer", + "lock pc", +] + + +[[commands]] +id = "screenshot" +type = "lua" +script = "screenshot.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "сделай скриншот", + "скриншот", + "сними экран", +] +en = [ + "take screenshot", + "screenshot", + "capture screen", +] diff --git a/resources/commands/apps/lock_screen.lua b/resources/commands/apps/lock_screen.lua new file mode 100644 index 0000000..956384f --- /dev/null +++ b/resources/commands/apps/lock_screen.lua @@ -0,0 +1,8 @@ +local res = jarvis.system.exec("rundll32.exe user32.dll,LockWorkStation") +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "lock screen failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/apps/open_browser.lua b/resources/commands/apps/open_browser.lua new file mode 100644 index 0000000..c1061ac --- /dev/null +++ b/resources/commands/apps/open_browser.lua @@ -0,0 +1,9 @@ +local cmd = "start \"\" \"https://www.google.com\"" +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "open browser failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/apps/open_calculator.lua b/resources/commands/apps/open_calculator.lua new file mode 100644 index 0000000..0afe795 --- /dev/null +++ b/resources/commands/apps/open_calculator.lua @@ -0,0 +1,8 @@ +local res = jarvis.system.exec("start \"\" calc.exe") +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "open calculator failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/apps/open_explorer.lua b/resources/commands/apps/open_explorer.lua new file mode 100644 index 0000000..5c6a2e4 --- /dev/null +++ b/resources/commands/apps/open_explorer.lua @@ -0,0 +1,8 @@ +local res = jarvis.system.exec("start \"\" explorer.exe") +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "open explorer failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/apps/open_notepad.lua b/resources/commands/apps/open_notepad.lua new file mode 100644 index 0000000..023c4f1 --- /dev/null +++ b/resources/commands/apps/open_notepad.lua @@ -0,0 +1,8 @@ +local res = jarvis.system.exec("start \"\" notepad.exe") +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "open notepad failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/apps/open_settings.lua b/resources/commands/apps/open_settings.lua new file mode 100644 index 0000000..786ad13 --- /dev/null +++ b/resources/commands/apps/open_settings.lua @@ -0,0 +1,8 @@ +local res = jarvis.system.exec("start \"\" ms-settings:") +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "open settings failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/apps/open_task_manager.lua b/resources/commands/apps/open_task_manager.lua new file mode 100644 index 0000000..b7d0f18 --- /dev/null +++ b/resources/commands/apps/open_task_manager.lua @@ -0,0 +1,8 @@ +local res = jarvis.system.exec("start \"\" taskmgr.exe") +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "open task manager failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/apps/open_terminal.lua b/resources/commands/apps/open_terminal.lua new file mode 100644 index 0000000..cf91242 --- /dev/null +++ b/resources/commands/apps/open_terminal.lua @@ -0,0 +1,13 @@ +local res = jarvis.system.exec("start \"\" wt.exe") +if not res.success then + jarvis.log("warn", "wt.exe not found, falling back to powershell") + res = jarvis.system.exec("start \"\" powershell.exe") +end + +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "open terminal failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/apps/screenshot.lua b/resources/commands/apps/screenshot.lua new file mode 100644 index 0000000..4d24243 --- /dev/null +++ b/resources/commands/apps/screenshot.lua @@ -0,0 +1,24 @@ +local ts = jarvis.context.time.year .. jarvis.context.time.month .. jarvis.context.time.day + .. "_" .. jarvis.context.time.hour .. jarvis.context.time.minute .. jarvis.context.time.second + +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local target = userprofile .. "\\Pictures\\jarvis_screenshot_" .. ts .. ".png" + +local ps = string.format( + [[Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object Drawing.Bitmap $b.Width,$b.Height; $g=[Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); $bmp.Save('%s'); $g.Dispose(); $bmp.Dispose()]], + target:gsub("\\", "\\\\") +) + +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"')) +local res = jarvis.system.exec(cmd) + +if res.success then + jarvis.log("info", "screenshot saved to " .. target) + jarvis.system.notify("Скриншот", target) + jarvis.audio.play_ok() +else + jarvis.log("error", "screenshot failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/backup/command.toml b/resources/commands/backup/command.toml new file mode 100644 index 0000000..f9bae48 --- /dev/null +++ b/resources/commands/backup/command.toml @@ -0,0 +1,18 @@ +# Backup all user state (memory, profiles, schedule, macros) to a ZIP. + +[[commands]] +id = "backup.export" +type = "lua" +script = "export.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = [ + "сделай бекап", + "экспортируй настройки", + "сохрани мои настройки", + "экспорт данных", + "выгрузи настройки", +] +en = ["backup", "export settings"] diff --git a/resources/commands/backup/export.lua b/resources/commands/backup/export.lua new file mode 100644 index 0000000..5dfa49d --- /dev/null +++ b/resources/commands/backup/export.lua @@ -0,0 +1,46 @@ +-- Zip all user-state JSON files into ~/Documents/jarvis-backup-.zip. +local t = jarvis.context.time +local stamp = string.format("%04d%02d%02d-%02d%02d%02d", + t.year, t.month, t.day, t.hour, t.minute, t.second or 0) + +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local out_zip = userprofile .. "\\Documents\\jarvis-backup-" .. stamp .. ".zip" + +-- The state files live under . We can't easily read that path +-- from Lua, but the user can navigate from %APPDATA%/Jarvis. So we ask +-- PowerShell to do the discovery + zip in one shot. +local ps = string.format([[ +$cfg = Join-Path $env:APPDATA 'Jarvis'; +if (-not (Test-Path $cfg)) { Write-Output 'NOCFG'; exit }; +$files = @(); +foreach ($n in @('long_term_memory.json','schedule.json','macros.json','active_profile.txt','llm_backend.txt','settings.json')) { + $p = Join-Path $cfg $n; + if (Test-Path $p) { $files += $p } +}; +$profiles_dir = Join-Path $cfg 'profiles'; +if (Test-Path $profiles_dir) { + $files += Get-ChildItem $profiles_dir -File | ForEach-Object { $_.FullName } +}; +if ($files.Count -eq 0) { Write-Output 'NONE'; exit }; +Compress-Archive -Path $files -DestinationPath '%s' -Force; +Write-Output ('OK|' + $files.Count) +]], out_zip:gsub("'", "''")) + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'):gsub("\r?\n", "; ") +)) +if not res.success then + return jarvis.cmd.error("Не получилось создать бекап.") +end + +local out = (res.stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") +if out == "NOCFG" then + return jarvis.cmd.not_found("Папка настроек не найдена.") +end +if out == "NONE" then + return jarvis.cmd.not_found("Нечего бекапить — нет данных.") +end + +local count = out:match("OK|(%d+)") or "?" +jarvis.system.notify("Backup готов", out_zip) +return jarvis.cmd.ok(string.format("Бекап сохранён, %s файлов.", count)) diff --git a/resources/commands/banter/command.toml b/resources/commands/banter/command.toml new file mode 100644 index 0000000..4ef5156 --- /dev/null +++ b/resources/commands/banter/command.toml @@ -0,0 +1,64 @@ +# Idle-banter control voice commands. The banter background thread is started +# unconditionally; gating happens via JARVIS_IDLE_BANTER env. These commands +# let the user trigger / pause it without touching env. + +[[commands]] +id = "banter.fire" +type = "lua" +script = "fire.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "скажи что-нибудь интересное", + "пошути", + "развлеки меня", + "скучно мне", +] +en = [ + "say something interesting", + "tell me something", + "entertain me", + "i'm bored", +] + + +[[commands]] +id = "banter.pause" +type = "lua" +script = "pause.lua" +sandbox = "minimal" +timeout = 1500 + +[commands.phrases] +ru = [ + "помолчи", + "не отвлекай меня", + "выключи болтовню", +] +en = [ + "be quiet", + "stop chatting", + "stop chiming in", +] + + +[[commands]] +id = "banter.resume" +type = "lua" +script = "resume.lua" +sandbox = "minimal" +timeout = 1500 + +[commands.phrases] +ru = [ + "можешь говорить", + "включи болтовню", + "верни комментарии", +] +en = [ + "you can speak again", + "resume chatting", + "you may comment", +] diff --git a/resources/commands/banter/fire.lua b/resources/commands/banter/fire.lua new file mode 100644 index 0000000..dbc905a --- /dev/null +++ b/resources/commands/banter/fire.lua @@ -0,0 +1,10 @@ +-- Manually trigger a banter line right now (ignores interval + quiet hours). +local fired = jarvis.banter.fire() +if not fired then + return jarvis.cmd.not_found( + jarvis.context.language == "ru" + and "Сэр, в этот раз без комментариев." + or "No remark today, sir." + ) +end +return { chain = true } diff --git a/resources/commands/banter/pause.lua b/resources/commands/banter/pause.lua new file mode 100644 index 0000000..ba44456 --- /dev/null +++ b/resources/commands/banter/pause.lua @@ -0,0 +1,6 @@ +jarvis.banter.pause() +return jarvis.cmd.ok( + jarvis.context.language == "ru" + and "Молчу, сэр." + or "Quiet, sir." +) diff --git a/resources/commands/banter/resume.lua b/resources/commands/banter/resume.lua new file mode 100644 index 0000000..1ac830b --- /dev/null +++ b/resources/commands/banter/resume.lua @@ -0,0 +1,6 @@ +jarvis.banter.resume() +return jarvis.cmd.ok( + jarvis.context.language == "ru" + and "Хорошо, сэр. Возвращаюсь к комментариям." + or "Very well, sir. Resuming remarks." +) diff --git a/resources/commands/birthdays/add.lua b/resources/commands/birthdays/add.lua new file mode 100644 index 0000000..5c17956 --- /dev/null +++ b/resources/commands/birthdays/add.lua @@ -0,0 +1,80 @@ +-- Voice "запомни день рождения мама 15 марта" → memory["birthday.мама"]="15.03" +-- We accept three date formats: "15.03", "15 марта", and "15 march". + +local lang = jarvis.context.language +local raw = (jarvis.context.phrase or ""):lower() + +local triggers = { + "запомни день рождения", "день рождения у", "добавь день рождения", + "запиши день рождения", + "remember birthday", "add birthday", "save birthday", +} +local rest = jarvis.text.strip_trigger(raw, triggers) or raw +rest = rest:gsub("^%s+", ""):gsub("%s+$", "") + +-- Russian month names → "MM" +local months_ru = { + ["января"]=1, ["январь"]=1, + ["февраля"]=2, ["февраль"]=2, + ["марта"]=3, ["март"]=3, + ["апреля"]=4, ["апрель"]=4, + ["мая"]=5, ["май"]=5, + ["июня"]=6, ["июнь"]=6, + ["июля"]=7, ["июль"]=7, + ["августа"]=8, ["август"]=8, + ["сентября"]=9, ["сентябрь"]=9, + ["октября"]=10, ["октябрь"]=10, + ["ноября"]=11, ["ноябрь"]=11, + ["декабря"]=12, ["декабрь"]=12, +} +local months_en = { + january=1, february=2, march=3, april=4, may=5, june=6, + july=7, august=8, september=9, october=10, november=11, december=12, +} + +-- Try patterns in order. Each pattern leaves `name` and `date` populated. +local name, date_str = nil, nil + +-- 1. " DD.MM" +local n, d, m = rest:match("^(.-)%s+(%d?%d)%.(%d?%d)") +if n and d and m then + name = n + date_str = string.format("%02d.%02d", tonumber(d), tonumber(m)) +end + +-- 2. " DD " +if not name then + local n2, d2, mword = rest:match("^(.-)%s+(%d?%d)%s+(%S+)") + if n2 and d2 and mword then + local m_num = months_ru[mword] or months_en[mword] + if m_num then + name = n2 + date_str = string.format("%02d.%02d", tonumber(d2), m_num) + end + end +end + +if not name or not date_str then + return jarvis.cmd.not_found(lang == "ru" + and "Скажи в формате 'имя 15 марта' или 'имя 15.03'." + or "Say in the form 'name 15 march' or 'name 15.03'.") +end + +name = name:gsub("^%s+", ""):gsub("%s+$", "") +if name == "" then + return jarvis.cmd.not_found(lang == "ru" and "Чьё именно?" or "Whose?") +end + +-- Strip leading prepositions left from "у мамы" → "мамы" +name = name:gsub("^у%s+", ""):gsub("^of%s+", ""):gsub("^for%s+", "") + +local ok, err = pcall(function() + jarvis.memory.remember("birthday." .. name, date_str) +end) +if not ok then + return jarvis.cmd.error(tostring(err)) +end + +return jarvis.cmd.ok(string.format(lang == "ru" + and "Запомнил: %s — %s." + or "Got it: %s — %s.", name, date_str)) diff --git a/resources/commands/birthdays/command.toml b/resources/commands/birthdays/command.toml new file mode 100644 index 0000000..154ac2c --- /dev/null +++ b/resources/commands/birthdays/command.toml @@ -0,0 +1,50 @@ +# Birthday tracker — voice-add and check upcoming birthdays. +# Storage uses `jarvis.memory.remember` with keys "birthday." so the +# data lives in the same long-term memory store and survives across packs. +# +# Voice flow: +# "запомни день рождения мама 15 марта" +# → memory key "birthday.мама" = "15.03" +# "ближайший день рождения" → reads all "birthday.*" keys + tells the next +# "день рождения мама" → reads back what we know about Mom + +[[commands]] +id = "birthdays.add" +type = "lua" +script = "add.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "запомни день рождения", + "день рождения у", + "добавь день рождения", + "запиши день рождения", +] +en = [ + "remember birthday", + "add birthday", + "save birthday", +] + + +[[commands]] +id = "birthdays.next" +type = "lua" +script = "next.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "ближайший день рождения", + "у кого скоро день рождения", + "кто следующий именинник", + "когда следующий день рождения", +] +en = [ + "next birthday", + "whose birthday is next", + "upcoming birthdays", +] diff --git a/resources/commands/birthdays/next.lua b/resources/commands/birthdays/next.lua new file mode 100644 index 0000000..a061f4b --- /dev/null +++ b/resources/commands/birthdays/next.lua @@ -0,0 +1,55 @@ +-- Read all "birthday.*" memory keys, find the next upcoming date. + +local lang = jarvis.context.language +local now = jarvis.context.time + +local entries = {} +for _, f in ipairs(jarvis.memory.all() or {}) do + if f.key and f.key:sub(1, 9) == "birthday." then + local d, m = f.value:match("^(%d?%d)%.(%d?%d)") + if d and m then + table.insert(entries, { + name = f.key:sub(10), + day = tonumber(d), + month = tonumber(m), + }) + end + end +end + +if #entries == 0 then + return jarvis.cmd.not_found(lang == "ru" + and "Дней рождения в памяти нет, сэр." + or "No birthdays stored, sir.") +end + +-- Compute "days until" for each. Wrap to next year if month/day already passed. +local function days_until(e) + local today_idx = now.month * 31 + now.day + local their_idx = e.month * 31 + e.day + local delta = their_idx - today_idx + if delta < 0 then + delta = delta + 12 * 31 -- approximate "wrap to next year" + end + return delta +end + +for _, e in ipairs(entries) do + e.delta = days_until(e) +end +table.sort(entries, function(a, b) return a.delta < b.delta end) + +local nxt = entries[1] +local human +if nxt.delta == 0 then + human = lang == "ru" and "сегодня" or "today" +elseif nxt.delta == 1 then + human = lang == "ru" and "завтра" or "tomorrow" +else + human = string.format(lang == "ru" and "через %d дней" or "in %d days", nxt.delta) +end + +return jarvis.cmd.ok(string.format(lang == "ru" + and "Ближайший — %s, %s (%02d.%02d)." + or "Next: %s, %s (%02d.%02d).", + nxt.name, human, nxt.day, nxt.month)) diff --git a/resources/commands/brightness/command.toml b/resources/commands/brightness/command.toml new file mode 100644 index 0000000..9981743 --- /dev/null +++ b/resources/commands/brightness/command.toml @@ -0,0 +1,46 @@ +[[commands]] +id = "brightness_up" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["ярче", "сделай ярче", "увеличь яркость", "поярче"] +en = ["brighter", "increase brightness"] + + +[[commands]] +id = "brightness_down" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["темнее", "сделай темнее", "уменьши яркость", "потемнее"] +en = ["darker", "decrease brightness"] + + +[[commands]] +id = "brightness_max" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["максимальная яркость", "яркость на максимум"] +en = ["max brightness", "brightness max"] + + +[[commands]] +id = "brightness_min" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["минимальная яркость", "яркость на минимум"] +en = ["min brightness", "brightness min"] diff --git a/resources/commands/brightness/set.lua b/resources/commands/brightness/set.lua new file mode 100644 index 0000000..ee2088b --- /dev/null +++ b/resources/commands/brightness/set.lua @@ -0,0 +1,44 @@ +local lang = jarvis.context.language +local cmd_id = jarvis.context.command_id + +local ps = [[ +$ErrorActionPreference = 'Stop' +try { + $cur = (Get-WmiObject -Namespace root\WMI -Class WmiMonitorBrightness -ErrorAction Stop).CurrentBrightness +} catch { + Write-Output 'no_wmi' + exit 0 +} +$target = switch ('__OP__') { + 'up' { [Math]::Min(100, $cur + 20) } + 'down' { [Math]::Max(0, $cur - 20) } + 'max' { 100 } + 'min' { 10 } + default { $cur } +} +$m = Get-WmiObject -Namespace root\WMI -Class WmiMonitorBrightnessMethods +$m.WmiSetBrightness(1, $target) | Out-Null +Write-Output $target +]] + +local op = ({ + brightness_up = "up", brightness_down = "down", + brightness_max = "max", brightness_min = "min", +})[cmd_id] or "up" + +local script = ps:gsub("__OP__", op) +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', script:gsub('"', '\\"')) +local res = jarvis.system.exec(cmd) +local out = (res.stdout or ""):gsub("[\r\n]+$", ""):gsub("^%s+", "") + +if out == "no_wmi" or not res.success then + jarvis.system.notify("Brightness", + lang == "ru" and "Не поддерживается на этом дисплее" + or "Not supported on this display") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.system.notify(lang == "ru" and "Яркость" or "Brightness", out .. "%") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/browser/command.toml b/resources/commands/browser/command.toml.disabled similarity index 100% rename from resources/commands/browser/command.toml rename to resources/commands/browser/command.toml.disabled diff --git a/resources/commands/clip_history/command.toml b/resources/commands/clip_history/command.toml new file mode 100644 index 0000000..d622643 --- /dev/null +++ b/resources/commands/clip_history/command.toml @@ -0,0 +1,52 @@ +# Clipboard history — store last 20 copies, recall by index/keyword. + +[[commands]] +id = "cliphist.save" +type = "lua" +script = "save.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "запиши буфер", + "сохрани буфер", + "запомни буфер", + "запомни что в буфере", +] +en = ["save clipboard", "remember clipboard"] + + +[[commands]] +id = "cliphist.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "что я копировал", + "история буфера", + "последние буферы", + "что было в буфере", +] +en = ["clipboard history", "what did I copy"] + + +[[commands]] +id = "cliphist.restore" +type = "lua" +script = "restore.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "верни первый буфер", + "верни второй буфер", + "верни третий буфер", + "восстанови буфер", + "вставь старый буфер", +] +en = ["restore clipboard", "paste old clipboard"] diff --git a/resources/commands/clip_history/list.lua b/resources/commands/clip_history/list.lua new file mode 100644 index 0000000..08f1c37 --- /dev/null +++ b/resources/commands/clip_history/list.lua @@ -0,0 +1,21 @@ +-- Speak first 30 chars of each stored clip. +local count = 0 +local line = "В истории буфера: " +for i = 0, 19 do + local val = jarvis.memory.recall("clip." .. i) + if val then + count = count + 1 + if count <= 3 then + local preview = val:sub(1, 30):gsub("\r?\n", " ") + line = line .. (count == 1 and "" or " | ") .. preview + if count == 3 then break end + end + end +end + +if count == 0 then + return jarvis.cmd.ok("История буфера пуста.") +end + +line = line .. ". Всего " .. count .. " записей." +return jarvis.cmd.ok(line) diff --git a/resources/commands/clip_history/restore.lua b/resources/commands/clip_history/restore.lua new file mode 100644 index 0000000..52e5bef --- /dev/null +++ b/resources/commands/clip_history/restore.lua @@ -0,0 +1,21 @@ +-- "Верни первый/второй/третий буфер" — copy to clipboard. +local phrase = (jarvis.context.phrase or ""):lower() +local idx = 0 -- "первый" = 0 (most recent) +if phrase:find("втор") then idx = 1 +elseif phrase:find("трет") then idx = 2 +elseif phrase:find("четверт") then idx = 3 +elseif phrase:find("пят") then idx = 4 +end + +-- Allow explicit number "вставь буфер 3" +local n = phrase:match("(%d+)") +if n then idx = tonumber(n) - 1 end -- 1-based → 0-based + +local content = jarvis.memory.recall("clip." .. idx) +if not content then + return jarvis.cmd.not_found("В истории нет такой записи.") +end + +jarvis.system.clipboard.set(content) +local preview = content:sub(1, 30):gsub("\r?\n", " ") +return jarvis.cmd.ok("Восстановил: " .. preview .. ".") diff --git a/resources/commands/clip_history/save.lua b/resources/commands/clip_history/save.lua new file mode 100644 index 0000000..2f1039b --- /dev/null +++ b/resources/commands/clip_history/save.lua @@ -0,0 +1,24 @@ +-- Take whatever's in the clipboard and push into history (memory keys clip.0..clip.19, rotated). +local content = jarvis.system.clipboard.get() +if not content or content == "" then + return jarvis.cmd.not_found("Буфер пуст.") +end + +-- Shift existing entries: clip.18 → clip.19, clip.17 → clip.18, etc. +for i = 19, 1, -1 do + local prev = jarvis.memory.recall("clip." .. (i - 1)) + if prev then + jarvis.memory.remember("clip." .. i, prev) + end +end + +-- Truncate if huge +local trimmed = content +if #trimmed > 2000 then + trimmed = trimmed:sub(1, 2000) .. "..." +end +jarvis.memory.remember("clip.0", trimmed) + +-- Speak first 40 chars as confirmation +local preview = trimmed:sub(1, 40):gsub("\r?\n", " ") +return jarvis.cmd.ok("Запомнил: " .. preview .. ".") diff --git a/resources/commands/clipboard_read/command.toml b/resources/commands/clipboard_read/command.toml new file mode 100644 index 0000000..28bdb62 --- /dev/null +++ b/resources/commands/clipboard_read/command.toml @@ -0,0 +1,10 @@ +[[commands]] +id = "read_clipboard" +type = "lua" +script = "read.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = ["прочитай буфер", "что в буфере", "прочитай из буфера", "озвучь буфер"] +en = ["read clipboard", "what is in clipboard", "speak clipboard"] diff --git a/resources/commands/clipboard_read/read.lua b/resources/commands/clipboard_read/read.lua new file mode 100644 index 0000000..6a28f6f --- /dev/null +++ b/resources/commands/clipboard_read/read.lua @@ -0,0 +1,26 @@ +local lang = jarvis.context.language +local raw = jarvis.system.clipboard.get() or "" +local text = raw:gsub("^%s+", ""):gsub("%s+$", "") + +if text == "" then + jarvis.system.notify( + lang == "ru" and "Буфер" or "Clipboard", + lang == "ru" and "Пусто" or "Empty" + ) + jarvis.audio.play_not_found() + return { chain = false } +end + +local preview_chars = 400 +local to_speak = text +if #to_speak > preview_chars then + to_speak = to_speak:sub(1, preview_chars) .. (lang == "ru" and " ... и так далее" or " ... and so on") +end + +jarvis.system.notify( + lang == "ru" and "Буфер" or "Clipboard", + text:sub(1, 120) +) +jarvis.speak(to_speak, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/codebase_qa/ask.lua b/resources/commands/codebase_qa/ask.lua new file mode 100644 index 0000000..e6bfaca --- /dev/null +++ b/resources/commands/codebase_qa/ask.lua @@ -0,0 +1,98 @@ +-- "что делает функция X" / "найди в коде Y" +-- Reads a digest of source files in the configured codebase root, sends to LLM. +local root = jarvis.memory.recall("codebase.root") +if not root or root == "" then + return jarvis.cmd.not_found("Сначала укажите проект.") +end + +local phrase = (jarvis.context.phrase or "") +local question = jarvis.text.strip_trigger(phrase:lower(), { + "что делает функция", + "вопрос по коду", + "спроси код", + "найди в проекте", + "найди в коде", + "что в коде", + "объясни код", + "ask the codebase", + "ask the code", + "what does the function", + "explain the code", +}) +question = question:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") +if question == "" then + return jarvis.cmd.error("Сформулируйте вопрос.") +end + +-- Build a digest: walk depth-2, pick source files by extension, cap per-file size + total size. +local EXT_OK = { + rs=true, lua=true, py=true, ts=true, tsx=true, js=true, jsx=true, svelte=true, + go=true, java=true, kt=true, c=true, h=true, cpp=true, hpp=true, cs=true, + rb=true, php=true, sh=true, ps1=true, sql=true, toml=true, yaml=true, yml=true, + json=true, md=true, +} +local SKIP_DIR = { + [".git"]=true, ["target"]=true, ["node_modules"]=true, ["dist"]=true, ["build"]=true, + ["__pycache__"]=true, [".venv"]=true, ["venv"]=true, [".idea"]=true, [".vscode"]=true, +} + +local MAX_FILE_BYTES = 4096 -- ~1k tokens per file +local MAX_TOTAL_BYTES = 50000 -- ~12k tokens total for digest +local MAX_FILES = 30 + +local function walk(dir, depth, acc) + if depth > 3 then return end + local entries = jarvis.fs.list(dir) + if not entries then return end + for _, ent in ipairs(entries) do + if #acc.files >= MAX_FILES or acc.bytes >= MAX_TOTAL_BYTES then + return + end + local name = ent.name + local full = dir .. "\\" .. name + if ent.is_dir then + if not SKIP_DIR[name] and not name:match("^%.") then + walk(full, depth + 1, acc) + end + else + local ext = name:match("%.([%w]+)$") + if ext and EXT_OK[ext:lower()] then + local content = jarvis.fs.read(full) + if content then + if #content > MAX_FILE_BYTES then + content = content:sub(1, MAX_FILE_BYTES) .. "\n... [truncated]" + end + -- relative path for readability + local rel = full:sub(#root + 2) + table.insert(acc.files, "--- " .. rel .. " ---\n" .. content) + acc.bytes = acc.bytes + #content + end + end + end + end +end + +local acc = { files = {}, bytes = 0 } +walk(root, 1, acc) + +if #acc.files == 0 then + return jarvis.cmd.not_found("Не нашёл исходников в проекте.") +end + +local digest = table.concat(acc.files, "\n\n") +local prompt = string.format( + "Ты — старший разработчик. По digest проекта ответь на вопрос пользователя кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n=== ВОПРОС ===\n%s\n\n=== КОД ===\n%s", + question, digest +) + +local reply = jarvis.llm({ + { role = "system", content = "Ты — внимательный код-ревьюер. Отвечай по существу, без воды." }, + { role = "user", content = prompt } +}, { max_tokens = 400, temperature = 0.2 }) + +if not reply or reply == "" then + return jarvis.cmd.error("Не получилось получить ответ.") +end + +jarvis.system.notify("Codebase Q&A", reply:sub(1, 250)) +return jarvis.cmd.ok(reply) diff --git a/resources/commands/codebase_qa/command.toml b/resources/commands/codebase_qa/command.toml new file mode 100644 index 0000000..456f2d7 --- /dev/null +++ b/resources/commands/codebase_qa/command.toml @@ -0,0 +1,58 @@ +# Codebase Q&A — point J.A.R.V.I.S. at a folder, ask questions about the code. +# Active folder stored via jarvis.memory (key="codebase.root"). Voice commands let +# you swap the folder, list it, and ask questions. The script reads files lazily +# (depth-limited, size-capped) and feeds a digest to the LLM. + +[[commands]] +id = "codebase.set" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "укажи проект", + "укажи папку проекта", + "укажи кодовую базу", + "выбери проект", + "проект сейчас", +] +en = ["set codebase", "set project folder", "use project"] + + +[[commands]] +id = "codebase.ask" +type = "lua" +script = "ask.lua" +sandbox = "full" +timeout = 60000 + +[commands.phrases] +ru = [ + "спроси код", + "вопрос по коду", + "что в коде", + "что делает функция", + "найди в коде", + "найди в проекте", + "объясни код", +] +en = ["ask the code", "ask the codebase", "what does the function", "explain the code"] + + +[[commands]] +id = "codebase.where" +type = "lua" +script = "where.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "какой проект сейчас", + "где проект", + "какая папка проекта", + "какая кодовая база", +] +en = ["which project", "what's the codebase"] diff --git a/resources/commands/codebase_qa/set.lua b/resources/commands/codebase_qa/set.lua new file mode 100644 index 0000000..82b7075 --- /dev/null +++ b/resources/commands/codebase_qa/set.lua @@ -0,0 +1,29 @@ +-- "Укажи проект C:\Jarvis\rust" — stores path in memory under key "codebase.root" +local phrase = (jarvis.context.phrase or "") + +local body = jarvis.text.strip_trigger(phrase:lower(), { + "укажи папку проекта", + "укажи кодовую базу", + "укажи проект", + "выбери проект", + "проект сейчас", + "set codebase", + "set project folder", + "use project", +}) +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- Path can have backslashes/spaces — preserve original casing by picking from raw phrase +local raw = phrase:sub(#phrase - #body + 1):gsub("^[%s,:%.]+", ""):gsub("%s+$", "") +local path = raw ~= "" and raw or body + +if path == "" then + return jarvis.cmd.error("Укажите путь к папке проекта.") +end + +if not jarvis.fs.is_dir(path) then + return jarvis.cmd.error("Папка не найдена: " .. path) +end + +jarvis.memory.remember("codebase.root", path) +return jarvis.cmd.ok("Проект установлен.") diff --git a/resources/commands/codebase_qa/where.lua b/resources/commands/codebase_qa/where.lua new file mode 100644 index 0000000..28b3376 --- /dev/null +++ b/resources/commands/codebase_qa/where.lua @@ -0,0 +1,5 @@ +local root = jarvis.memory.recall("codebase.root") +if not root or root == "" then + return jarvis.cmd.not_found("Проект не выбран. Скажите: укажи проект.") +end +return jarvis.cmd.ok("Сейчас работаю с проектом " .. root .. ".") diff --git a/resources/commands/codegen/codegen.lua b/resources/commands/codegen/codegen.lua new file mode 100644 index 0000000..36f2c5f --- /dev/null +++ b/resources/commands/codegen/codegen.lua @@ -0,0 +1,99 @@ +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +local triggers = { + "сгенерируй скрипт", "напиши скрипт", "сделай скрипт", + "сгенерируй код", "набросай код", "напиши код", + "generate code", "write code", "make script", "write script", + "згенеруй код", "напиши код", "зроби скрипт", +} + +local request = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(request, t, 1, true) + if s == 1 then + request = request:sub(e + 1) + break + end +end +request = request:gsub("^%s+", ""):gsub("%s+$", "") + +if request == "" then + jarvis.system.notify( + lang == "ru" and "Codegen" or "Codegen", + lang == "ru" and "Что писать?" or "What to write?" + ) + jarvis.audio.play_error() + return { chain = false } +end + +local token = jarvis.system.env("GROQ_TOKEN") +if not token or token == "" then + jarvis.log("error", "GROQ_TOKEN not set") + jarvis.system.notify("Codegen", "GROQ_TOKEN не задан") + jarvis.audio.play_error() + return { chain = false } +end + +local base = jarvis.system.env("GROQ_BASE_URL") +if not base or base == "" then base = "https://api.groq.com/openai/v1" end + +local model = jarvis.system.env("GROQ_MODEL") +if not model or model == "" then model = "llama-3.3-70b-versatile" end + +local system_prompt = lang == "ru" + and "Ты Senior Engineer. На запрос пользователя верни ТОЛЬКО исходный код, без обрамления ``` и без объяснений. Если язык не задан — выбери самый подходящий (по умолчанию Python). Не добавляй комментарии-болтовню." + or "You are a Senior Engineer. Reply with ONLY source code, no ``` fences, no commentary. Pick the most fitting language if unspecified (Python by default). Skip chatty comments." + +jarvis.log("info", "codegen request: " .. request) + +local payload = { + model = model, + messages = { + { role = "system", content = system_prompt }, + { role = "user", content = request }, + }, + max_tokens = 1024, + temperature = 0.2, +} + +local headers = { Authorization = "Bearer " .. token } +local res = jarvis.http.post_json(base .. "/chat/completions", payload, headers) + +if not res.ok then + jarvis.log("error", "codegen API failed: status=" .. tostring(res.status) .. " body=" .. tostring(res.body):sub(1, 200)) + jarvis.system.notify("Codegen", "Ошибка API: " .. tostring(res.status)) + jarvis.audio.play_error() + return { chain = false } +end + +local body = res.body or "" +local content = body:match('"content"%s*:%s*"(.-[^\\])"') +if not content then + jarvis.log("error", "codegen: could not parse content from " .. body:sub(1, 200)) + jarvis.system.notify("Codegen", "Не смог распарсить ответ") + jarvis.audio.play_error() + return { chain = false } +end + +content = content:gsub('\\n', '\n') +content = content:gsub('\\"', '"') +content = content:gsub('\\\\', '\\') +content = content:gsub('\\t', '\t') + +content = content:gsub("^```[%w]*\n?", "") +content = content:gsub("\n?```%s*$", "") +content = content:gsub("^%s+", ""):gsub("%s+$", "") + +jarvis.system.clipboard.set(content) +jarvis.log("info", "codegen result copied to clipboard (" .. #content .. " bytes)") + +local preview = content:sub(1, 120) +if #content > 120 then preview = preview .. "..." end +jarvis.system.notify( + lang == "ru" and "Код в буфере" or "Code in clipboard", + preview +) +jarvis.audio.play_ok() + +return { chain = false } diff --git a/resources/commands/codegen/command.toml b/resources/commands/codegen/command.toml new file mode 100644 index 0000000..f296b54 --- /dev/null +++ b/resources/commands/codegen/command.toml @@ -0,0 +1,22 @@ +[[commands]] +id = "codegen_clipboard" +type = "lua" +script = "codegen.lua" +sandbox = "full" +timeout = 30000 + +[commands.phrases] +ru = [ + "напиши код", + "сгенерируй код", + "набросай код", + "сделай скрипт", + "напиши скрипт", + "сгенерируй скрипт", +] +en = [ + "write code", + "generate code", + "make script", + "write script", +] diff --git a/resources/commands/conversation/command.toml b/resources/commands/conversation/command.toml new file mode 100644 index 0000000..93c6f2c --- /dev/null +++ b/resources/commands/conversation/command.toml @@ -0,0 +1,45 @@ +# Conversation tools — pull the persisted LLM history for review or replay. + +[[commands]] +id = "conversation.summary" +type = "lua" +script = "summary.lua" +sandbox = "full" +timeout = 20000 + +[commands.phrases] +ru = [ + "о чём мы говорили", + "о чем мы говорили", + "напомни о чем мы говорили", + "напомни про что мы говорили", + "что мы недавно обсуждали", + "сделай выжимку разговора", +] +en = [ + "what were we talking about", + "summarize our conversation", + "what did we discuss", + "recap our chat", +] + + +[[commands]] +id = "conversation.repeat" +type = "lua" +script = "repeat_last.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "повтори", + "повтори последнее", + "что ты сказал", + "не расслышал", +] +en = [ + "say that again", + "repeat", + "i didn't catch that", +] diff --git a/resources/commands/conversation/repeat_last.lua b/resources/commands/conversation/repeat_last.lua new file mode 100644 index 0000000..c1870af --- /dev/null +++ b/resources/commands/conversation/repeat_last.lua @@ -0,0 +1,13 @@ +-- Repeat the last assistant message verbatim. Useful when the user didn't catch +-- it (loud room, distraction, etc). + +local lang = jarvis.context.language +local last = jarvis.llm_last_reply() + +if not last or last == "" then + return jarvis.cmd.not_found(lang == "ru" + and "Я ещё ничего не говорил, сэр." + or "I haven't said anything yet, sir.") +end + +return jarvis.cmd.ok(last) diff --git a/resources/commands/conversation/summary.lua b/resources/commands/conversation/summary.lua new file mode 100644 index 0000000..1882895 --- /dev/null +++ b/resources/commands/conversation/summary.lua @@ -0,0 +1,79 @@ +-- Summarise the last N turns of LLM conversation in 1-2 sentences. +-- +-- Reads the persistent history via `jarvis.llm_history()`, builds a short +-- recap prompt, asks the LLM. If there's no history yet or the LLM is +-- unreachable, falls back to a polite "ничего не вспомнить" reply. + +local lang = jarvis.context.language + +local turns = jarvis.llm_history() or {} +-- Take only the last 12 user/assistant turns to keep prompt small. +local recent = {} +local start = math.max(1, #turns - 11) +for i = start, #turns do + if turns[i] then table.insert(recent, turns[i]) end +end + +if #recent == 0 then + return jarvis.cmd.not_found(lang == "ru" + and "Мы пока ещё ни о чём не говорили, сэр." + or "We haven't talked about anything yet, sir.") +end + +-- Render history as a compact "Пользователь: ... / Я: ..." dialogue. +local lines = {} +for _, m in ipairs(recent) do + local who = m.role == "assistant" and (lang == "ru" and "Я" or "Me") + or (lang == "ru" and "Пользователь" or "User") + table.insert(lines, who .. ": " .. (m.content or "")) +end +local transcript = table.concat(lines, "\n") + +local token = jarvis.system.env("GROQ_TOKEN") +if not token or token == "" then + -- Offline path: just speak the user's last message back briefly. + return jarvis.cmd.ok(lang == "ru" + and ("Без сети, сэр. Последний раз вы спросили: " .. (recent[#recent].content or ""):sub(1, 200)) + or ("No network, sir. Last topic: " .. (recent[#recent].content or ""):sub(1, 200))) +end + +local base = jarvis.system.env("GROQ_BASE_URL") +if not base or base == "" then base = "https://api.groq.com/openai/v1" end +local model = jarvis.system.env("GROQ_MODEL") +if not model or model == "" then model = "llama-3.3-70b-versatile" end + +local sys +if lang == "ru" then + sys = "Ты — J.A.R.V.I.S. По ниже идущему диалогу с пользователем составь короткое " + .. "напоминание (1-3 предложения), о чём шла речь. Говори от первого лица в " + .. "стиле британского дворецкого. Не пересказывай дословно — сожми суть." +else + sys = "You are J.A.R.V.I.S. From the dialogue below, write a brief recap " + .. "(1-3 sentences) of what we talked about. Use first person, British " + .. "butler tone. Don't quote verbatim — distil the essence." +end + +local payload = { + model = model, + messages = { + { role = "system", content = sys }, + { role = "user", content = transcript }, + }, + max_tokens = 200, + temperature = 0.4, +} + +local res = jarvis.http.post_json(base .. "/chat/completions", payload, + { Authorization = "Bearer " .. token }) +if not res.ok then + return jarvis.cmd.error(lang == "ru" and "LLM не отвечает." or "LLM didn't respond.") +end + +local content = (res.body or ""):match('"content"%s*:%s*"(.-[^\\])"') +if not content then + return jarvis.cmd.error(lang == "ru" and "Не смог разобрать ответ." or "Couldn't parse reply.") +end +content = content:gsub('\\n', ' '):gsub('\\"', '"'):gsub('\\\\', '\\') +content = content:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + +return jarvis.cmd.ok(content) diff --git a/resources/commands/cooking/command.toml b/resources/commands/cooking/command.toml new file mode 100644 index 0000000..af9d561 --- /dev/null +++ b/resources/commands/cooking/command.toml @@ -0,0 +1,44 @@ +# Cooking timer — recognises common dishes and starts a preset timer. +# "поставь чайник" → 4 min, "омлет" → 5 min, "макароны" → 10 min, etc. +# The phrase decides the duration via a built-in table in the lua script. + +[[commands]] +id = "cooking.timer" +type = "lua" +script = "timer.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "поставь чайник", + "поставил чайник", + "варю яйца", + "варю макароны", + "варю пасту", + "жарю омлет", + "жарю яичницу", + "варю кашу", + "варю рис", + "варю гречку", + "варю картошку", + "запекаю курицу", + "пеку пиццу", + "завариваю чай", + "завариваю кофе", + "завариваю френч-пресс", +] +en = [ + "boiling water", + "boiling eggs", + "boiling pasta", + "cooking pasta", + "frying omelette", + "frying eggs", + "cooking rice", + "cooking porridge", + "baking pizza", + "brewing tea", + "brewing coffee", + "french press", +] diff --git a/resources/commands/cooking/timer.lua b/resources/commands/cooking/timer.lua new file mode 100644 index 0000000..eef3de7 --- /dev/null +++ b/resources/commands/cooking/timer.lua @@ -0,0 +1,84 @@ +-- Cooking timer — recognise the dish in the phrase and schedule a "ready!" +-- reminder for the right number of minutes. Falls through to a generic +-- "couldn't recognise" reply if nothing matched. +-- +-- The duration table is opinionated but covers the common Russian kitchen +-- staples. Power users can override via `recipes.txt` (TODO future) — for +-- now, edit this file or use the generic `reminders/set` pack. + +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +-- pattern -> { minutes, ru_label, en_label } +local recipes = { + { "чайник", 4, "чайник", "kettle" }, + { "boiling water",4, "вода", "boiling water" }, + { "французск", 4, "френч-пресс", "french press" }, + { "french press", 4, "френч-пресс", "french press" }, + { "чай", 5, "чай", "tea" }, + { "brewing tea", 5, "чай", "tea" }, + { "омлет", 5, "омлет", "omelette" }, + { "омлеt", 5, "омлет", "omelette" }, -- mis-STT guard + { "яичниц", 5, "яичница", "fried eggs" }, + { "frying eggs", 5, "яичница", "fried eggs" }, + { "frying omelet",5, "омлет", "omelette" }, + { "кофе", 5, "кофе", "coffee" }, + { "brewing coff", 5, "кофе", "coffee" }, + { "яйц", 8, "яйца", "eggs" }, + { "boiling eggs", 8, "яйца", "eggs" }, + { "макарон", 10, "макароны", "pasta" }, + { "паст", 10, "паста", "pasta" }, + { "cooking pasta",10, "паста", "pasta" }, + { "boiling pasta",10, "паста", "pasta" }, + { "рис", 18, "рис", "rice" }, + { "cooking rice", 18, "рис", "rice" }, + { "греч", 20, "гречка", "buckwheat" }, + { "каш", 20, "каша", "porridge" }, + { "porridge", 20, "каша", "porridge" }, + { "картош", 22, "картошка", "potatoes" }, + { "пицц", 15, "пицца", "pizza" }, + { "baking pizza", 15, "пицца", "pizza" }, + { "куриц", 35, "курица", "chicken" }, + { "chicken", 35, "курица", "chicken" }, +} + +local matched_minutes, label_ru, label_en = nil, nil, nil +for _, r in ipairs(recipes) do + if phrase:find(r[1], 1, true) then + matched_minutes, label_ru, label_en = r[2], r[3], r[4] + break + end +end + +if not matched_minutes then + return jarvis.cmd.not_found(lang == "ru" + and "Не понял, что ты готовишь, сэр." + or "I didn't catch what you're cooking, sir.") +end + +local label = (lang == "ru") and label_ru or label_en +local speak_text = (lang == "ru" and "Сэр, " or "Sir, ") + .. label .. (lang == "ru" and " готов." or " is ready.") + +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = (lang == "ru" and "Кухня: " or "Cooking: ") .. label, + schedule = "in " .. tostring(matched_minutes) .. " minutes", + action = { type = "speak", text = speak_text }, + }) +end) +if not ok then + jarvis.log("error", "cooking timer: scheduler.add failed: " .. tostring(err)) + return jarvis.cmd.error(lang == "ru" and "Не получилось поставить таймер." + or "Couldn't set timer.") +end + +jarvis.system.notify( + lang == "ru" and "Таймер" or "Timer", + string.format("%s — %d мин", label, matched_minutes) +) + +return jarvis.cmd.ok(string.format(lang == "ru" + and "Хорошо, напомню через %d минут когда %s будет готов." + or "Right — I'll remind you in %d minutes when %s is ready.", + matched_minutes, label)) diff --git a/resources/commands/crypto/command.toml b/resources/commands/crypto/command.toml new file mode 100644 index 0000000..a03086a --- /dev/null +++ b/resources/commands/crypto/command.toml @@ -0,0 +1,23 @@ +[[commands]] +id = "crypto_price" +type = "lua" +script = "price.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = [ + "сколько биткоин", + "цена биткоина", + "курс биткоина", + "цена эфира", + "сколько эфир", + "цена крипты", + "цена соланы", +] +en = [ + "bitcoin price", + "ethereum price", + "crypto price", + "btc price", +] diff --git a/resources/commands/crypto/price.lua b/resources/commands/crypto/price.lua new file mode 100644 index 0000000..97f33d5 --- /dev/null +++ b/resources/commands/crypto/price.lua @@ -0,0 +1,51 @@ +local phrase = (jarvis.context.phrase or ""):lower() + +local coin_id, label +if phrase:find("эфир") or phrase:find("eth") or phrase:find("ethereum") then + coin_id, label = "ethereum", "Эфир" +elseif phrase:find("солан") or phrase:find("sol") then + coin_id, label = "solana", "Солана" +elseif phrase:find("doge") or phrase:find("дог") then + coin_id, label = "dogecoin", "Догикоин" +elseif phrase:find("ton") or phrase:find("тон") then + coin_id, label = "the-open-network", "TON" +else + coin_id, label = "bitcoin", "Биткоин" +end + +local url = "https://api.coingecko.com/api/v3/simple/price?ids=" .. coin_id .. "&vs_currencies=usd,rub&include_24hr_change=true" +local res = jarvis.http.json(url) +if not res or type(res) ~= "table" or not res[coin_id] then + jarvis.system.notify("Crypto", "CoinGecko не ответил") + jarvis.audio.play_error() + return { chain = false } +end + +local data = res[coin_id] +local usd = data.usd +local rub = data.rub +local ch = data.usd_24h_change or 0 + +local title = string.format("%s: $%s | %s ₽", label, tostring(usd), tostring(math.floor(rub or 0))) +local detail = string.format("за 24 часа %+.2f%%", ch) +jarvis.system.notify(title, detail) +jarvis.log("info", title .. " | " .. detail) + +local say +if ch > 1 then + say = string.format("%s стоит %s долларов, за сутки вырос на %.1f процентов.", label, tostring(math.floor(usd)), ch) +elseif ch < -1 then + say = string.format("%s стоит %s долларов, за сутки упал на %.1f процентов.", label, tostring(math.floor(usd)), math.abs(ch)) +else + say = string.format("%s стоит %s долларов, без существенных изменений.", label, tostring(math.floor(usd))) +end + +local sapi = say:gsub("'", "''") +local ps = string.format( + [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], + sapi +) +jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) + +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/currency/command.toml b/resources/commands/currency/command.toml new file mode 100644 index 0000000..87effbb --- /dev/null +++ b/resources/commands/currency/command.toml @@ -0,0 +1,53 @@ +[[commands]] +id = "currency_rate" +type = "lua" +script = "rate.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = [ + "курс доллара", + "курс евро", + "сколько стоит доллар", + "сколько стоит евро", + "курс юаня", + "курс валют", + "что с долларом", + "что с евро", +] +en = [ + "dollar rate", + "euro rate", + "currency rate", + "what is dollar", +] + + +# Convert N units of one currency to another, e.g. +# "1000 долларов в рубли" → "1000 USD = 99523 ₽" +[[commands]] +id = "currency_convert" +type = "lua" +script = "convert.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = [ + "сколько будет", + "переведи", + "конвертируй", + "долларов в рубли", + "евро в рубли", + "долларов в евро", + "юаней в рубли", + "рублей в доллары", + "рублей в евро", +] +en = [ + "convert", + "how much is", + "dollars in rubles", + "euros in rubles", +] diff --git a/resources/commands/currency/convert.lua b/resources/commands/currency/convert.lua new file mode 100644 index 0000000..d60cf1c --- /dev/null +++ b/resources/commands/currency/convert.lua @@ -0,0 +1,121 @@ +-- "Сколько будет 1000 долларов в рублях" / "переведи 50 евро в доллары" +-- Uses cbr-xml-daily.ru (CBR daily rates, JSON, no key, mirrors RU central bank). +local phrase = (jarvis.context.phrase or ""):lower() + +-- Strip trigger words +local body = phrase:gsub("сколько будет", "") + :gsub("переведи", "") + :gsub("конвертируй", "") + :gsub("convert", "") + :gsub("how much is", "") + :gsub("^[%s,:%.]+", "") + :gsub("%s+$", "") + +-- Extract amount (digit or russian "сто", "тысяча" — keep simple, digits only) +local amount_str = body:match("([%d%.,]+)") +if not amount_str then + jarvis.speak("Не понял сумму.") + jarvis.audio.play_error() + return { chain = false } +end +local amount = tonumber((amount_str:gsub(",", "."))) +if not amount then + jarvis.speak("Не понял сумму.") + jarvis.audio.play_error() + return { chain = false } +end + +-- Detect source + target currencies via heuristic substring matches. +local function detect(text) + if text:find("доллар") or text:find("usd") or text:find("dollar") then return "USD" end + if text:find("евро") or text:find("eur") or text:find("euro") then return "EUR" end + if text:find("юан") or text:find("cny") or text:find("yuan") then return "CNY" end + if text:find("фунт") or text:find("gbp") or text:find("pound") then return "GBP" end + if text:find("иен") or text:find("jpy") or text:find("yen") then return "JPY" end + if text:find("рубл") or text:find("rub") or text:find("ruble") then return "RUB" end + return nil +end + +-- Split on the conversion word "в"/"в"/"to"/"into" to detect from/to. +local from_part, to_part +local in_idx = body:find("%s+в%s+") or body:find("%s+to%s+") or body:find("%s+into%s+") +if in_idx then + from_part = body:sub(1, in_idx - 1) + to_part = body:sub(in_idx + 1) +else + -- Heuristic: only one currency named → assume target RUB. + from_part = body + to_part = "рубли" +end + +local from_ccy = detect(from_part) +local to_ccy = detect(to_part) or "RUB" +if not from_ccy then + jarvis.speak("Не понял исходную валюту.") + jarvis.audio.play_error() + return { chain = false } +end + +-- Fetch CBR rates (RUB-denominated). +local body_json = jarvis.http.get("https://www.cbr-xml-daily.ru/daily_json.js") +if not body_json or body_json == "" then + jarvis.speak("Не получилось получить курсы.") + jarvis.audio.play_error() + return { chain = false } +end + +-- We can't safely parse JSON without a Lua JSON lib. Use jarvis.http.json instead. +local data = jarvis.http.json("https://www.cbr-xml-daily.ru/daily_json.js") +if not data or not data.Valute then + jarvis.speak("Не получилось разобрать ответ.") + jarvis.audio.play_error() + return { chain = false } +end + +-- Each Valute entry: { Value=rub_per_unit, Nominal=N }. Effective rub_per_unit = Value/Nominal. +local function rub_per(ccy) + if ccy == "RUB" then return 1.0 end + local v = data.Valute[ccy] + if not v then return nil end + return v.Value / v.Nominal +end + +local rub_from = rub_per(from_ccy) +local rub_to = rub_per(to_ccy) +if not rub_from or not rub_to then + jarvis.speak("Этой валюты нет в курсах ЦБ.") + jarvis.audio.play_error() + return { chain = false } +end + +local result = amount * rub_from / rub_to + +-- Format pretty +local function fmt(n) + if n >= 1000 then + return string.format("%d", math.floor(n + 0.5)) + elseif n >= 10 then + return string.format("%.1f", n) + else + return string.format("%.2f", n) + end +end + +local function unit(ccy, n) + if ccy == "RUB" then return (n == 1 and "рубль" or (n < 5 and "рубля" or "рублей")) end + if ccy == "USD" then return (n == 1 and "доллар" or (n < 5 and "доллара" or "долларов")) end + if ccy == "EUR" then return "евро" end + if ccy == "CNY" then return (n == 1 and "юань" or (n < 5 and "юаня" or "юаней")) end + if ccy == "GBP" then return (n == 1 and "фунт" or (n < 5 and "фунта" or "фунтов")) end + if ccy == "JPY" then return "иен" end + return ccy +end + +local result_int = math.floor(result + 0.5) +local line = string.format("%s %s — это %s %s.", + fmt(amount), unit(from_ccy, amount), + fmt(result), unit(to_ccy, result_int)) + +jarvis.speak(line) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/currency/rate.lua b/resources/commands/currency/rate.lua new file mode 100644 index 0000000..ea154b6 --- /dev/null +++ b/resources/commands/currency/rate.lua @@ -0,0 +1,59 @@ +local phrase = (jarvis.context.phrase or ""):lower() + +local target = "usd" +if phrase:find("евро") or phrase:find("eur") then target = "eur" +elseif phrase:find("юан") or phrase:find("cny") or phrase:find("yuan") then target = "cny" +elseif phrase:find("фунт") or phrase:find("gbp") then target = "gbp" +elseif phrase:find("йен") or phrase:find("jpy") then target = "jpy" +elseif phrase:find("тенге") or phrase:find("kzt") then target = "kzt" +end + +local res = jarvis.http.json("https://www.cbr-xml-daily.ru/daily_json.js") +if not res or type(res) ~= "table" or not res.Valute then + jarvis.system.notify("Курс", "Не получил данные ЦБ") + jarvis.audio.play_error() + return { chain = false } +end + +local labels = { + usd = "Доллар", eur = "Евро", cny = "Юань", + gbp = "Фунт", jpy = "Йена", kzt = "Тенге", +} +local code = target:upper() +local v = res.Valute[code] +if not v then + jarvis.system.notify("Курс", "Нет данных по " .. code) + jarvis.audio.play_not_found() + return { chain = false } +end + +local value = v.Value +local prev = v.Previous or value +local diff = value - prev +local arrow = diff > 0 and "▲" or (diff < 0 and "▼" or "=") +local nominal = v.Nominal or 1 + +local title = string.format("%s: %.2f ₽ %s", labels[target] or code, value / nominal, arrow) +local detail = string.format("вчера %.2f, изменение %+.2f", prev / nominal, diff / nominal) + +jarvis.system.notify(title, detail) +jarvis.log("info", title .. " | " .. detail) + +local say +if diff > 0 then + say = string.format("%s стоит %.2f рубля, поднялся на %.2f.", labels[target] or code, value / nominal, math.abs(diff) / nominal) +elseif diff < 0 then + say = string.format("%s стоит %.2f рубля, опустился на %.2f.", labels[target] or code, value / nominal, math.abs(diff) / nominal) +else + say = string.format("%s стоит %.2f рубля, без изменений.", labels[target] or code, value / nominal) +end + +local sapi = say:gsub("'", "''") +local ps = string.format( + [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], + sapi +) +jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) + +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/daily_briefing/command.toml b/resources/commands/daily_briefing/command.toml new file mode 100644 index 0000000..ee90267 --- /dev/null +++ b/resources/commands/daily_briefing/command.toml @@ -0,0 +1,51 @@ +# Daily briefing — chains time + weather + memory + scheduler at a fixed time. + +[[commands]] +id = "daily_briefing.setup" +type = "lua" +script = "setup.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "настрой утренний брифинг", + "включи утренний брифинг", + "начни утренний брифинг каждый день", + "сделай утренний брифинг", +] +en = ["set up morning briefing", "enable morning briefing", "daily briefing"] + + +[[commands]] +id = "daily_briefing.now" +type = "lua" +script = "now.lua" +sandbox = "standard" +timeout = 15000 + +[commands.phrases] +ru = [ + "утренний брифинг", + "сделай брифинг", + "брифинг сейчас", + "что нового", + "сводка дня", +] +en = ["morning briefing", "give me a briefing", "what's new today"] + + +[[commands]] +id = "daily_briefing.off" +type = "lua" +script = "off.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "отключи утренний брифинг", + "выключи утренний брифинг", + "отмени брифинг", +] +en = ["disable morning briefing", "cancel morning briefing"] diff --git a/resources/commands/daily_briefing/now.lua b/resources/commands/daily_briefing/now.lua new file mode 100644 index 0000000..3fedb96 --- /dev/null +++ b/resources/commands/daily_briefing/now.lua @@ -0,0 +1,64 @@ +-- The actual briefing body. Called either directly ("утренний брифинг") OR +-- by the scheduler at the configured daily time. +local t = jarvis.context.time +local lang = jarvis.context.language or "ru" + +-- 1) Greeting + time +local hour = t.hour +local greeting +if hour >= 5 and hour < 12 then greeting = "Доброе утро." +elseif hour >= 12 and hour < 18 then greeting = "Добрый день." +elseif hour >= 18 and hour < 23 then greeting = "Добрый вечер." +else greeting = "Сэр." end + +jarvis.speak(greeting .. string.format(" Сейчас %d:%02d.", hour, t.minute)) +jarvis.sleep(300) + +-- 2) Profile-aware tone +local prof = jarvis.profile.active() +if prof and prof.name and prof.name ~= "default" then + jarvis.speak("Активный режим: " .. prof.name .. ".") + jarvis.sleep(200) +end + +-- 3) Scheduled tasks ahead today +local tasks = jarvis.scheduler.list() +if tasks and #tasks > 0 then + local count = 0 + for _, task in ipairs(tasks) do + if task.action and task.action.type == "speak" then + count = count + 1 + end + end + if count > 0 then + jarvis.speak(string.format("Запланировано задач: %d.", count)) + jarvis.sleep(200) + end +end + +-- 4) Memory recap (most recently used 3) +local memos = jarvis.memory.all() +if memos and #memos > 0 then + local sample = math.min(2, #memos) + local line = "Из памяти: " + for i = 1, sample do + line = line .. memos[i].key .. " — " .. memos[i].value + if i < sample then line = line .. "; " end + end + line = line .. "." + jarvis.speak(line) + jarvis.sleep(200) +end + +-- 5) Closing nudge from LLM if available +local nudge = jarvis.llm({ + { role = "system", content = "Ты — Джарвис. Одна короткая фраза-мотиватор на день. По-русски. 5-10 слов." }, + { role = "user", content = string.format("Сейчас %d часов %02d минут, режим: %s.", hour, t.minute, prof and prof.name or "default") } +}, { max_tokens = 60, temperature = 0.8 }) + +if nudge and nudge ~= "" then + jarvis.speak(nudge) +end + +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/daily_briefing/off.lua b/resources/commands/daily_briefing/off.lua new file mode 100644 index 0000000..c4e2c6e --- /dev/null +++ b/resources/commands/daily_briefing/off.lua @@ -0,0 +1,4 @@ +if jarvis.scheduler.remove("daily_briefing_main") then + return jarvis.cmd.ok("Утренний брифинг отключен.") +end +return jarvis.cmd.not_found("Брифинг и так не был настроен.") diff --git a/resources/commands/daily_briefing/setup.lua b/resources/commands/daily_briefing/setup.lua new file mode 100644 index 0000000..4da1f92 --- /dev/null +++ b/resources/commands/daily_briefing/setup.lua @@ -0,0 +1,38 @@ +-- "Настрой утренний брифинг на 9:00" → adds a daily scheduled task that runs now.lua +local phrase = (jarvis.context.phrase or ""):lower() + +-- try to extract HH:MM, default 9:00 +local h, m = phrase:match("(%d%d?)[:%s%-](%d%d)") +if not h then + h = phrase:match("(%d%d?)") +end +local hh = tonumber(h) or 9 +local mm = tonumber(m) or 0 +if hh < 0 or hh > 23 or mm < 0 or mm > 59 then hh = 9; mm = 0 end + +local script_path = jarvis.context.command_path .. "/now.lua" +script_path = script_path:gsub("/", "\\") + +local id = "daily_briefing_main" + +local ok, err = pcall(function() + -- replace any prior briefing + jarvis.scheduler.remove(id) + jarvis.scheduler.add({ + id = id, + name = "Daily briefing", + schedule = string.format("daily %02d:%02d", hh, mm), + action = { type = "lua", script_path = script_path }, + }) +end) + +if not ok then + jarvis.log("warn", "daily_briefing.setup: " .. tostring(err)) + jarvis.speak("Не получилось настроить брифинг.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak(string.format("Утренний брифинг настроен на %02d:%02d.", hh, mm)) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/daily_quote/command.toml b/resources/commands/daily_quote/command.toml new file mode 100644 index 0000000..e3ac4d1 --- /dev/null +++ b/resources/commands/daily_quote/command.toml @@ -0,0 +1,18 @@ +# Daily quote — random motivational quote. + +[[commands]] +id = "daily_quote" +type = "lua" +script = "quote.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "цитата дня", + "мотивирующая цитата", + "скажи цитату", + "вдохнови меня", + "цитата", +] +en = ["quote of the day", "motivational quote", "inspire me"] diff --git a/resources/commands/daily_quote/quote.lua b/resources/commands/daily_quote/quote.lua new file mode 100644 index 0000000..8109352 --- /dev/null +++ b/resources/commands/daily_quote/quote.lua @@ -0,0 +1,31 @@ +-- "Цитата дня" — random quote. +-- Source: zenquotes.io (free, no key, returns JSON array) +local data = jarvis.http.json("https://zenquotes.io/api/random") +if not data or type(data) ~= "table" or #data == 0 then + -- Fallback to LLM if API fails + local llm_quote = jarvis.llm({ + { role = "system", content = "Выдай одну короткую мотивирующую цитату. По-русски. Без вступлений." }, + { role = "user", content = "Цитата дня." } + }, { max_tokens = 100, temperature = 0.9 }) + if llm_quote and llm_quote ~= "" then + return jarvis.cmd.ok(llm_quote) + end + return jarvis.cmd.error("Не получилось получить цитату.") +end + +local q = data[1] +local quote_en = q.q or "" +local author = q.a or "Аноним" +if quote_en == "" then + return jarvis.cmd.error("Пустая цитата.") +end + +-- Translate to Russian via LLM (the API returns English). +local translated = jarvis.llm({ + { role = "system", content = "Переведи английскую цитату на русский. Без вступлений. Только перевод." }, + { role = "user", content = quote_en } +}, { max_tokens = 200, temperature = 0.2 }) + +local text = (translated and translated ~= "") and translated or quote_en + +return jarvis.cmd.ok(text .. " — " .. author .. ".") diff --git a/resources/commands/date_math/command.toml b/resources/commands/date_math/command.toml new file mode 100644 index 0000000..8e17daf --- /dev/null +++ b/resources/commands/date_math/command.toml @@ -0,0 +1,35 @@ +# Date arithmetic — "сколько дней до нового года" / "сколько до зарплаты 15-го" + +[[commands]] +id = "date.days_until" +type = "lua" +script = "days_until.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "сколько дней до", + "сколько до нового года", + "сколько до", + "до нового года", + "когда", +] +en = ["how many days until", "days until"] + + +[[commands]] +id = "date.day_of_week" +type = "lua" +script = "day_of_week.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "какой сегодня день недели", + "какой день недели", + "какой сейчас день", + "сегодня какой", +] +en = ["what day is today", "day of week"] diff --git a/resources/commands/date_math/day_of_week.lua b/resources/commands/date_math/day_of_week.lua new file mode 100644 index 0000000..be127ab --- /dev/null +++ b/resources/commands/date_math/day_of_week.lua @@ -0,0 +1,21 @@ +local t = jarvis.context.time + +-- Compute day-of-week using Zeller's congruence (0=Saturday). +local y, m, d = t.year, t.month, t.day +if m < 3 then m = m + 12; y = y - 1 end +local K = y % 100 +local J = math.floor(y / 100) +local h = (d + math.floor(13 * (m + 1) / 5) + K + math.floor(K / 4) + math.floor(J / 4) - 2 * J) % 7 +-- h: 0=Saturday, 1=Sunday, 2=Monday, ... 6=Friday + +local days_ru = { + [0] = "суббота", + [1] = "воскресенье", + [2] = "понедельник", + [3] = "вторник", + [4] = "среда", + [5] = "четверг", + [6] = "пятница", +} + +return jarvis.cmd.ok("Сегодня " .. (days_ru[h] or "неизвестный день") .. ".") diff --git a/resources/commands/date_math/days_until.lua b/resources/commands/date_math/days_until.lua new file mode 100644 index 0000000..06cb88d --- /dev/null +++ b/resources/commands/date_math/days_until.lua @@ -0,0 +1,74 @@ +-- "Сколько дней до нового года" / "сколько до 15 марта" +local phrase = (jarvis.context.phrase or ""):lower() +local t = jarvis.context.time +local current_year = t.year + +local MONTHS = { + ["январ"]=1, ["феврал"]=2, ["март"]=3, ["апрел"]=4, ["май"]=5, ["мая"]=5, ["июн"]=6, + ["июл"]=7, ["август"]=8, ["сентябр"]=9, ["октябр"]=10, ["ноябр"]=11, ["декабр"]=12, +} + +-- Special phrases +local target_y, target_m, target_d + +if phrase:find("нов") and phrase:find("год") then + target_y, target_m, target_d = current_year + 1, 1, 1 + if t.month == 1 and t.day == 1 then target_y = current_year end +elseif phrase:find("рожд") then + target_y, target_m, target_d = current_year, 1, 7 -- ru orthodox +elseif phrase:find("8 март") or phrase:find("восьмого март") then + target_y, target_m, target_d = current_year, 3, 8 +elseif phrase:find("9 ма") or phrase:find("девятое ма") then + target_y, target_m, target_d = current_year, 5, 9 +end + +-- Generic "до N <месяц>" +if not target_y then + local d = tonumber(phrase:match("до%s+(%d+)")) + if d then + for stem, month in pairs(MONTHS) do + if phrase:find(stem) then + target_d, target_m, target_y = d, month, current_year + break + end + end + end +end + +if not target_y then + return jarvis.cmd.error("Не понял дату.") +end + +-- If target this year already passed, jump to next year +local function days_since_epoch(y, m, d) + -- approximate: works for diffs within ~100 years + local total = y * 365 + math.floor(y / 4) - math.floor(y / 100) + math.floor(y / 400) + local cum = { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 } + total = total + cum[m] + (d - 1) + -- leap-day adjustment + local leap = (y % 4 == 0 and y % 100 ~= 0) or (y % 400 == 0) + if leap and m > 2 then total = total + 1 end + return total +end + +local now = days_since_epoch(t.year, t.month, t.day) +local target = days_since_epoch(target_y, target_m, target_d) + +if target < now then + target_y = target_y + 1 + target = days_since_epoch(target_y, target_m, target_d) +end + +local diff = target - now +if diff == 0 then + return jarvis.cmd.ok("Сегодня.") +end + +-- Russian grammar: 1 день, 2-4 дня, 5+ дней +local last = diff % 10 +local unit = "дней" +if last == 1 and diff % 100 ~= 11 then unit = "день" +elseif last >= 2 and last <= 4 and (diff % 100 < 10 or diff % 100 >= 20) then unit = "дня" +end + +return jarvis.cmd.ok(string.format("%d %s.", diff, unit)) diff --git a/resources/commands/date_query/answer.lua b/resources/commands/date_query/answer.lua new file mode 100644 index 0000000..dabee1f --- /dev/null +++ b/resources/commands/date_query/answer.lua @@ -0,0 +1,37 @@ +local lang = jarvis.context.language +local cmd_id = jarvis.context.command_id + +local offsets = { today = 0, tomorrow = 1, yesterday = -1 } +local offset = offsets[cmd_id] or 0 + +local ps = string.format( + [[$ru = [System.Globalization.CultureInfo]::GetCultureInfo('ru-RU'); $d = (Get-Date).AddDays(%d); $d.ToString('dddd, dd MMMM yyyy', $ru)]], + offset +) +local res = jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) +local text = (res.stdout or ""):gsub("[\r\n]+$", "") + +if text == "" then + jarvis.audio.play_error() + return { chain = false } +end + +local labels = { + today = lang == "ru" and "Сегодня" or "Today", + tomorrow = lang == "ru" and "Завтра" or "Tomorrow", + yesterday = lang == "ru" and "Вчера" or "Yesterday", +} +local prefix = labels[cmd_id] or "" + +jarvis.system.notify(prefix, text) + +local say = prefix .. ", " .. text +local sapi = say:gsub("'", "''") +local sps = string.format( + [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], + sapi +) +jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', sps:gsub('"', '\\"'))) + +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/date_query/command.toml b/resources/commands/date_query/command.toml new file mode 100644 index 0000000..64aaebc --- /dev/null +++ b/resources/commands/date_query/command.toml @@ -0,0 +1,34 @@ +[[commands]] +id = "today" +type = "lua" +script = "answer.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["какой сегодня день", "какое сегодня число", "сегодня какое число", "что сегодня"] +en = ["what day is today", "what's today", "today date"] + + +[[commands]] +id = "tomorrow" +type = "lua" +script = "answer.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["какой день завтра", "что завтра", "завтра какое число", "какое число завтра"] +en = ["what day is tomorrow", "what's tomorrow", "tomorrow date"] + + +[[commands]] +id = "yesterday" +type = "lua" +script = "answer.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["какой был вчера день", "что было вчера", "вчера какое число было"] +en = ["what day was yesterday", "yesterday date"] diff --git a/resources/commands/ddg_answer/answer.lua b/resources/commands/ddg_answer/answer.lua new file mode 100644 index 0000000..1c44c8f --- /dev/null +++ b/resources/commands/ddg_answer/answer.lua @@ -0,0 +1,99 @@ +-- DuckDuckGo Instant Answer — short factual lookups WITHOUT an API key. +-- +-- Endpoint: https://api.duckduckgo.com/?q=&format=json&no_html=1 +-- +-- DDG returns a JSON object. We look at, in order: +-- 1. `AbstractText` — Wikipedia-style summary, usually 1–3 sentences +-- 2. `Answer` — direct answer for math / conversion / calc queries +-- 3. `Definition` — dictionary definitions +-- 4. `RelatedTopics[1].Text` — first related topic as last resort +-- +-- If nothing useful comes back, we open the regular DDG search page so the +-- user can read it themselves. + +local lang = jarvis.context.language +local raw = (jarvis.context.phrase or ""):lower() + +local triggers = { + "что такое", "кто такой", "кто такая", + "расскажи про", "расскажи о", + "найди информацию о", "найди информацию про", + "поищи в интернете", "поищи в сети", + "погугли что такое", + "what is", "who is", "tell me about", "look up", "search the web for", +} +local query = jarvis.text.strip_trigger(raw, triggers) +if not query or query == "" then query = raw end +query = query:gsub("^%s+", ""):gsub("%s+$", "") + +if query == "" then + return jarvis.cmd.not_found(lang == "ru" and "Что искать?" or "What to look up?") +end + +-- URL-encode (basic — DDG accepts both UTF-8 and percent-encoded) +local function url_encode(s) + return (s:gsub("([^%w%-_%.~])", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local url = "https://api.duckduckgo.com/?q=" + .. url_encode(query) + .. "&format=json&no_html=1&skip_disambig=1" + +jarvis.log("info", "ddg query: " .. query) +local res = jarvis.http.get(url, { ["User-Agent"] = "Mozilla/5.0 J.A.R.V.I.S." }) + +if not res or not res.ok then + return jarvis.cmd.error(lang == "ru" and "DDG не отвечает." or "DDG didn't respond.") +end + +local body = res.body or "" + +-- Helper: pull the first top-level JSON string field. Quick-and-dirty regex +-- works because DDG never nests the fields we care about, AND because +-- consequent `"` inside values are escaped as `\"`. +local function field(name) + local pat = '"' .. name .. '"%s*:%s*"((\\.[^"]*|[^"])*)"' + local v = body:match('"' .. name .. '"%s*:%s*"(.-)"') + if not v or v == "" then return nil end + -- unescape common JSON sequences + v = v:gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\n', ' '):gsub('\\t', ' ') + -- trim, collapse whitespace + v = v:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if v == "" then return nil end + return v +end + +-- First related topic — array element is itself an object with "Text". +local function first_related() + -- Find start of "RelatedTopics" array + local start_idx = body:find('"RelatedTopics"%s*:%s*%[') + if not start_idx then return nil end + -- Look for the first Text inside that array + local v = body:sub(start_idx):match('"Text"%s*:%s*"(.-)"') + if not v or v == "" then return nil end + v = v:gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\n', ' '):gsub('\\t', ' ') + v = v:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + -- Trim absurdly long blurbs to the first ~300 chars + if #v > 320 then v = v:sub(1, 300) .. "…" end + return v +end + +local answer = + field("AbstractText") + or field("Answer") + or field("Definition") + or first_related() + +if answer then + jarvis.log("info", "ddg answer found, " .. #answer .. " chars") + return jarvis.cmd.ok(answer) +end + +-- Nothing useful — open the search page so the user can read raw results. +local fallback = "https://duckduckgo.com/?q=" .. url_encode(query) +jarvis.system.open(fallback) +return jarvis.cmd.ok(lang == "ru" + and ("Ничего конкретного не нашёл, открываю поиск по запросу: " .. query) + or ("Nothing direct found, opening search for: " .. query)) diff --git a/resources/commands/ddg_answer/command.toml b/resources/commands/ddg_answer/command.toml new file mode 100644 index 0000000..64e1b76 --- /dev/null +++ b/resources/commands/ddg_answer/command.toml @@ -0,0 +1,31 @@ +# DuckDuckGo Instant Answer — no API key, no rate limit issues. +# Returns short factual answers to questions like "что такое X", "кто такой Y", +# "когда родился Z". Falls back to opening the search page if no instant answer. + +[[commands]] +id = "ddg.answer" +type = "lua" +script = "answer.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "что такое", + "кто такой", + "кто такая", + "расскажи про", + "расскажи о", + "найди информацию о", + "найди информацию про", + "поищи в интернете", + "поищи в сети", + "погугли что такое", +] +en = [ + "what is", + "who is", + "tell me about", + "look up", + "search the web for", +] diff --git a/resources/commands/diagnostics/command.toml b/resources/commands/diagnostics/command.toml new file mode 100644 index 0000000..1d499ca --- /dev/null +++ b/resources/commands/diagnostics/command.toml @@ -0,0 +1,20 @@ +# Diagnostics — speak a summary of the active runtime state. Useful when +# something is misbehaving and you want to know which backends are active. + +[[commands]] +id = "diagnostics.health" +type = "lua" +script = "health.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "диагностика", + "состояние системы", + "статус", + "что у тебя сейчас", + "проверка системы", + "доложи о себе", +] +en = ["diagnostics", "status report", "health check"] diff --git a/resources/commands/diagnostics/health.lua b/resources/commands/diagnostics/health.lua new file mode 100644 index 0000000..e38c360 --- /dev/null +++ b/resources/commands/diagnostics/health.lua @@ -0,0 +1,11 @@ +local h = jarvis.health() +local line = string.format( + "TTS: %s. LLM: %s. Профиль: %s. Памяти фактов: %d. Запланировано: %d. Язык: %s.", + h.tts_backend or "—", + h.llm_backend or "—", + h.active_profile or "—", + h.memory_facts or 0, + h.scheduled_tasks or 0, + h.language or "—" +) +return jarvis.cmd.ok(line) diff --git a/resources/commands/dice/command.toml b/resources/commands/dice/command.toml new file mode 100644 index 0000000..e07c642 --- /dev/null +++ b/resources/commands/dice/command.toml @@ -0,0 +1,34 @@ +[[commands]] +id = "coin_flip" +type = "lua" +script = "roll.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["подбрось монетку", "брось монетку", "орёл или решка", "орел или решка"] +en = ["flip a coin", "toss a coin", "heads or tails"] + + +[[commands]] +id = "roll_dice" +type = "lua" +script = "roll.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["брось кубик", "подбрось кубик", "кинь кубик"] +en = ["roll a dice", "roll the dice"] + + +[[commands]] +id = "random_number" +type = "lua" +script = "roll.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["случайное число", "выбери случайное число", "рандомное число"] +en = ["random number", "pick a random number"] diff --git a/resources/commands/dice/roll.lua b/resources/commands/dice/roll.lua new file mode 100644 index 0000000..5d908a9 --- /dev/null +++ b/resources/commands/dice/roll.lua @@ -0,0 +1,28 @@ +local lang = jarvis.context.language +local cmd_id = jarvis.context.command_id + +math.randomseed(os.time() + math.floor(jarvis.context.time.second * 7919)) + +local title, text, say + +if cmd_id == "coin_flip" then + local r = math.random(2) + text = (r == 1) and (lang == "ru" and "Орёл" or "Heads") or (lang == "ru" and "Решка" or "Tails") + title = lang == "ru" and "Монетка" or "Coin" + say = text +elseif cmd_id == "roll_dice" then + local r = math.random(1, 6) + text = tostring(r) + title = lang == "ru" and "Кубик" or "Dice" + say = (lang == "ru" and "Выпало " or "Rolled ") .. r +else + local r = math.random(1, 100) + text = tostring(r) + title = lang == "ru" and "Случайное число" or "Random number" + say = (lang == "ru" and "Число " or "Number ") .. r +end + +jarvis.system.notify(title, text) +jarvis.speak(say, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/disk/command.toml b/resources/commands/disk/command.toml new file mode 100644 index 0000000..20f7608 --- /dev/null +++ b/resources/commands/disk/command.toml @@ -0,0 +1,34 @@ +# Disk space — free GB on system drive, or named drive. + +[[commands]] +id = "disk.free" +type = "lua" +script = "free.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "сколько свободного места", + "сколько места на диске", + "сколько свободно на диске", + "место на диске", + "свободное место", +] +en = ["disk space", "free space", "how much disk"] + + +[[commands]] +id = "disk.list" +type = "lua" +script = "list.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "какие у меня диски", + "список дисков", + "перечень дисков", +] +en = ["list drives", "what drives do I have"] diff --git a/resources/commands/disk/free.lua b/resources/commands/disk/free.lua new file mode 100644 index 0000000..62870f5 --- /dev/null +++ b/resources/commands/disk/free.lua @@ -0,0 +1,35 @@ +-- "Сколько свободно на диске C" / "сколько места" +local phrase = (jarvis.context.phrase or ""):upper() +local letter = phrase:match("([A-Z])%s*ДИСК") or phrase:match("ДИСК%s*([A-Z])") or + phrase:match("DRIVE%s*([A-Z])") or "C" + +local ps = string.format( + "$d = Get-PSDrive %s -ErrorAction SilentlyContinue; " .. + "if ($d) { '{0}|{1}' -f " .. + "[math]::Round($d.Free/1GB,1), " .. + "[math]::Round(($d.Free + $d.Used)/1GB,0) } else { 'NONE' }", + letter +) +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"') +)) + +if not res.success then + return jarvis.cmd.error("Не получилось.") +end + +local out = (res.stdout or ""):gsub("%s+", "") +if out == "NONE" or out == "" then + return jarvis.cmd.not_found("Диск " .. letter .. " не найден.") +end + +local free_gb, total_gb = out:match("([%d%.]+)|(%d+)") +if not free_gb then + return jarvis.cmd.error("Не понял ответ системы.") +end + +local pct = math.floor(tonumber(free_gb) / tonumber(total_gb) * 100 + 0.5) +return jarvis.cmd.ok(string.format( + "На диске %s свободно %s гигабайт из %s, это %d процентов.", + letter, free_gb, total_gb, pct +)) diff --git a/resources/commands/disk/list.lua b/resources/commands/disk/list.lua new file mode 100644 index 0000000..dae3c6e --- /dev/null +++ b/resources/commands/disk/list.lua @@ -0,0 +1,30 @@ +local ps = + "Get-PSDrive -PSProvider FileSystem | " .. + "Where-Object { $_.Used -ne $null -or $_.Free -ne $null } | " .. + "ForEach-Object { '{0}:{1}' -f $_.Name, [math]::Round($_.Free/1GB,0) } | " .. + "ForEach-Object { Write-Host -NoNewline ($_+' ') }" + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"') +)) + +if not res.success then + return jarvis.cmd.error("Не получилось.") +end + +local out = (res.stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") +if out == "" then + return jarvis.cmd.not_found("Дисков не нашёл.") +end + +-- "C:120 D:300 E:50" → "C 120 ГБ, D 300 ГБ, ..." +local parts = {} +for letter, gb in out:gmatch("([A-Z]):(%d+)") do + table.insert(parts, letter .. " " .. gb .. " гигабайт") +end + +if #parts == 0 then + return jarvis.cmd.error("Не понял ответ системы.") +end + +return jarvis.cmd.ok("Свободно: " .. table.concat(parts, ", ") .. ".") diff --git a/resources/commands/echo/command.toml b/resources/commands/echo/command.toml new file mode 100644 index 0000000..a9f7cdc --- /dev/null +++ b/resources/commands/echo/command.toml @@ -0,0 +1,33 @@ +# Echo debug pack — Jarvis repeats what you said. Useful for testing mic+TTS +# without involving wake-word or LLM. + +[[commands]] +id = "echo.repeat" +type = "lua" +script = "repeat.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "повтори за мной", + "скажи как я", + "эхо", + "проверка эхо", +] +en = ["repeat after me", "echo"] + + +[[commands]] +id = "echo.what_did_i_say" +type = "lua" +script = "what.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "что я сказал", + "что ты услышал", +] +en = ["what did i say", "what did you hear"] diff --git a/resources/commands/echo/repeat.lua b/resources/commands/echo/repeat.lua new file mode 100644 index 0000000..8e77071 --- /dev/null +++ b/resources/commands/echo/repeat.lua @@ -0,0 +1,27 @@ +-- "Повтори за мной привет всем" +local phrase = (jarvis.context.phrase or "") +local body = jarvis.text.strip_trigger(phrase:lower(), { + "повтори за мной", + "проверка эхо", + "скажи как я", + "эхо", + "repeat after me", + "echo", + "повтори за мною", + "ехо", +}) +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + return jarvis.cmd.error("Что повторить?") +end + +-- Preserve original casing — strip_trigger lowercased, but we can grab from the +-- raw phrase by finding the lowercase prefix and skipping past it. +local raw_lower = phrase:lower() +local start_idx = raw_lower:find(body, 1, true) +if start_idx then + body = phrase:sub(start_idx, start_idx + #body - 1) +end + +return jarvis.cmd.ok(body) diff --git a/resources/commands/echo/what.lua b/resources/commands/echo/what.lua new file mode 100644 index 0000000..e9bd1f1 --- /dev/null +++ b/resources/commands/echo/what.lua @@ -0,0 +1,6 @@ +-- "Что я сказал" — отдаёт обратно всё что услышал. +local heard = jarvis.context.phrase or "" +if heard == "" then + return jarvis.cmd.not_found("Я ничего не услышал.") +end +return jarvis.cmd.ok("Вы сказали: " .. heard) diff --git a/resources/commands/expenses/breakdown.lua b/resources/commands/expenses/breakdown.lua new file mode 100644 index 0000000..ba8f698 --- /dev/null +++ b/resources/commands/expenses/breakdown.lua @@ -0,0 +1,55 @@ +-- Per-category breakdown for the last 7 days, sorted by amount desc. + +local lang = jarvis.context.language +local raw_state = jarvis.state.get("entries") or "" +if raw_state == "" then + return jarvis.cmd.not_found(lang == "ru" + and "Нечего разбивать, сэр." + or "Nothing to break down, sir.") +end + +local now = jarvis.context.time +local now_ts = os.time({ + year = now.year, month = now.month, day = now.day, + hour = now.hour, min = now.minute, sec = now.second or 0, +}) +local cutoff = now_ts - 7 * 86400 + +local by_cat = {} +for chunk in raw_state:gmatch("([^|]+)") do + local ts, am, ca = chunk:match("^(%d+),([%-%d%.]+),(.+)$") + ts = tonumber(ts); am = tonumber(am) + if ts and am and ts >= cutoff then + by_cat[ca] = (by_cat[ca] or 0) + am + end +end + +local rows = {} +for ca, total in pairs(by_cat) do + table.insert(rows, { ca = ca, total = total }) +end +table.sort(rows, function(a, b) return a.total > b.total end) + +if #rows == 0 then + return jarvis.cmd.ok(lang == "ru" + and "На этой неделе тишина по тратам, сэр." + or "Quiet week on spending, sir.") +end + +-- Top 5 categories spoken; rest collapses into "и другое" +local top = math.min(5, #rows) +local parts = {} +for i = 1, top do + table.insert(parts, string.format("%s %.0f", rows[i].ca, rows[i].total)) +end +local tail = "" +if #rows > top then + local rest_total = 0 + for i = top + 1, #rows do rest_total = rest_total + rows[i].total end + tail = string.format(lang == "ru" + and " и другое %.0f" or " and other %.0f", rest_total) +end + +return jarvis.cmd.ok(string.format(lang == "ru" + and "По категориям: %s%s." + or "By category: %s%s.", table.concat(parts, ", "), tail)) diff --git a/resources/commands/expenses/command.toml b/resources/commands/expenses/command.toml new file mode 100644 index 0000000..5dbc168 --- /dev/null +++ b/resources/commands/expenses/command.toml @@ -0,0 +1,91 @@ +# Personal-finance tracker — voice-log expenses by category, get totals. +# Storage is per-pack via `jarvis.state` (auto-namespaced JSON), so it doesn't +# bleed into other packs' data. +# +# Voice flow: +# "потратил 500 на еду" → log entry (amount=500, category="еда") +# "сколько я потратил сегодня" → speak today's total +# "сколько на этой неделе" → speak 7-day total +# "куда ушли деньги" → speak per-category breakdown for the week + +[[commands]] +id = "expenses.log" +type = "lua" +script = "log.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "потратил", + "записать трату", + "запиши трату", + "купил за", + "заплатил за", +] +en = [ + "spent", + "log expense", + "paid for", + "bought", +] + + +[[commands]] +id = "expenses.today" +type = "lua" +script = "today.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сколько я потратил сегодня", + "траты сегодня", + "сколько ушло сегодня", +] +en = [ + "spending today", + "expenses today", + "how much spent today", +] + + +[[commands]] +id = "expenses.week" +type = "lua" +script = "week.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сколько я потратил на этой неделе", + "траты на неделе", + "недельные траты", +] +en = [ + "spending this week", + "weekly expenses", +] + + +[[commands]] +id = "expenses.breakdown" +type = "lua" +script = "breakdown.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "куда ушли деньги", + "разбивка по тратам", + "на что я тратил", + "разбивка по категориям", +] +en = [ + "where did the money go", + "expense breakdown", + "spending by category", +] diff --git a/resources/commands/expenses/log.lua b/resources/commands/expenses/log.lua new file mode 100644 index 0000000..807b713 --- /dev/null +++ b/resources/commands/expenses/log.lua @@ -0,0 +1,74 @@ +-- Voice "потратил 500 на еду" → append {amount, category, timestamp} to the +-- pack's state JSON. +-- +-- Parsing the phrase: +-- 1. Strip the trigger ("потратил" / "spent" / ...) +-- 2. First number = amount +-- 3. After the number: optional "на <слово>" → category (RU) or "for " +-- / "on " (EN). Otherwise category defaults to "разное" / "misc". + +local lang = jarvis.context.language +local raw = (jarvis.context.phrase or ""):lower() + +local triggers = { + "потратил", "записать трату", "запиши трату", "купил за", "заплатил за", + "spent", "log expense", "paid for", "bought", +} +local rest = jarvis.text.strip_trigger(raw, triggers) or raw +rest = rest:gsub("^%s+", ""):gsub("%s+$", "") + +-- Pull the first integer-like token. Allows comma decimals ("12,50" → 12.5). +local amount_str, after = rest:match("(%-?[%d]+[%.,]?[%d]*)%s*(.*)") +if not amount_str then + return jarvis.cmd.not_found(lang == "ru" + and "Не понял сумму, сэр." + or "I didn't catch the amount, sir.") +end +amount_str = amount_str:gsub(",", ".") +local amount = tonumber(amount_str) +if not amount or amount <= 0 then + return jarvis.cmd.not_found(lang == "ru" + and "Сумма не похожа на число." + or "That doesn't look like an amount.") +end + +local category = "разное" +local cat_match = (after or ""):match("на%s+(%S+)") or (after or ""):match("for%s+(%S+)") + or (after or ""):match("on%s+(%S+)") +if cat_match and cat_match ~= "" then + category = cat_match +end + +-- Load existing log, append, save. +local raw_state = jarvis.state.get("entries") or "" +local entries = {} +if raw_state ~= "" then + -- Encoded as "ts1,amount1,cat1|ts2,amount2,cat2|..." + for chunk in raw_state:gmatch("([^|]+)") do + local ts, am, ca = chunk:match("^(%d+),([%-%d%.]+),(.+)$") + if ts and am and ca then + table.insert(entries, { ts = tonumber(ts), am = tonumber(am), ca = ca }) + end + end +end + +local now = jarvis.context.time +-- chrono::Local timestamp is exposed via {year, month, day, hour, minute, second} +-- but not as a unix epoch. Approximate via os.time({...}) — sandbox standard +-- gives us os.time/date. +local ts = os.time({ + year = now.year, month = now.month, day = now.day, + hour = now.hour, min = now.minute, sec = now.second or 0, +}) +table.insert(entries, { ts = ts, am = amount, ca = category }) + +local out = {} +for _, e in ipairs(entries) do + table.insert(out, string.format("%d,%s,%s", e.ts, tostring(e.am), e.ca)) +end +jarvis.state.set("entries", table.concat(out, "|")) + +return jarvis.cmd.ok(string.format(lang == "ru" + and "Записал: %s на %s." + or "Logged: %s on %s.", + amount_str, category)) diff --git a/resources/commands/expenses/today.lua b/resources/commands/expenses/today.lua new file mode 100644 index 0000000..8269d22 --- /dev/null +++ b/resources/commands/expenses/today.lua @@ -0,0 +1,36 @@ +-- Sum of today's expenses (local calendar day). + +local lang = jarvis.context.language +local raw_state = jarvis.state.get("entries") or "" + +if raw_state == "" then + return jarvis.cmd.not_found(lang == "ru" + and "Сегодня ничего не тратили, сэр." + or "No expenses logged today, sir.") +end + +local now = jarvis.context.time +local day_start = os.time({ + year = now.year, month = now.month, day = now.day, + hour = 0, min = 0, sec = 0, +}) +local day_end = day_start + 86400 + +local total = 0 +for chunk in raw_state:gmatch("([^|]+)") do + local ts, am = chunk:match("^(%d+),([%-%d%.]+),") + ts = tonumber(ts); am = tonumber(am) + if ts and am and ts >= day_start and ts < day_end then + total = total + am + end +end + +if total == 0 then + return jarvis.cmd.ok(lang == "ru" + and "Сегодня ничего не тратили, сэр." + or "Nothing spent today, sir.") +end + +return jarvis.cmd.ok(string.format(lang == "ru" + and "Сегодня потратили %.0f." + or "Today's spending: %.0f.", total)) diff --git a/resources/commands/expenses/week.lua b/resources/commands/expenses/week.lua new file mode 100644 index 0000000..25d6e41 --- /dev/null +++ b/resources/commands/expenses/week.lua @@ -0,0 +1,37 @@ +-- Sum of the last 7 days' expenses (rolling window). + +local lang = jarvis.context.language +local raw_state = jarvis.state.get("entries") or "" +if raw_state == "" then + return jarvis.cmd.not_found(lang == "ru" + and "На этой неделе ничего не тратили." + or "Nothing logged this week.") +end + +local now = jarvis.context.time +local now_ts = os.time({ + year = now.year, month = now.month, day = now.day, + hour = now.hour, min = now.minute, sec = now.second or 0, +}) +local cutoff = now_ts - 7 * 86400 + +local total = 0 +local count = 0 +for chunk in raw_state:gmatch("([^|]+)") do + local ts, am = chunk:match("^(%d+),([%-%d%.]+),") + ts = tonumber(ts); am = tonumber(am) + if ts and am and ts >= cutoff then + total = total + am + count = count + 1 + end +end + +if total == 0 then + return jarvis.cmd.ok(lang == "ru" + and "На этой неделе тратами не похваляешься, сэр." + or "Nothing tracked this week, sir.") +end + +return jarvis.cmd.ok(string.format(lang == "ru" + and "За неделю %d трат на сумму %.0f." + or "This week: %d expenses totalling %.0f.", count, total)) diff --git a/resources/commands/file_search/command.toml b/resources/commands/file_search/command.toml new file mode 100644 index 0000000..02b3755 --- /dev/null +++ b/resources/commands/file_search/command.toml @@ -0,0 +1,21 @@ +[[commands]] +id = "file_search" +type = "lua" +script = "search.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "найди файл", + "поиск файла", + "ищи файл", + "где файл", + "найди документ", +] +en = [ + "find file", + "search file", + "where is file", + "locate file", +] diff --git a/resources/commands/file_search/search.lua b/resources/commands/file_search/search.lua new file mode 100644 index 0000000..40d9365 --- /dev/null +++ b/resources/commands/file_search/search.lua @@ -0,0 +1,78 @@ +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +local triggers = { + "найди документ", "найди файл", "поиск файла", "ищи файл", "где файл", + "find file", "search file", "where is file", "locate file", + "знайди файл", "пошук файлу", "де файл", +} + +local query = phrase +for _, t in ipairs(triggers) do + local start, finish = string.find(query, t, 1, true) + if start == 1 then + query = query:sub(finish + 1) + break + end +end +query = query:gsub("^%s+", ""):gsub("%s+$", "") + +if query == "" then + jarvis.log("warn", "file_search: empty query (phrase=" .. phrase .. ")") + jarvis.system.notify( + lang == "ru" and "Поиск файлов" or "File search", + lang == "ru" and "Что искать?" or "What to search for?" + ) + jarvis.audio.play_error() + return { chain = false } +end + +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local roots = { + userprofile .. "\\Desktop", + userprofile .. "\\Documents", + userprofile .. "\\Downloads", +} + +local escaped = query:gsub("'", "''") +local roots_arg = "'" .. table.concat(roots, "','") .. "'" +local ps = string.format( + [[$ErrorActionPreference='SilentlyContinue'; $r=@(%s); $q='*%s*'; $hits=Get-ChildItem -Path $r -Filter $q -Recurse -Depth 3 -File | Select-Object -First 5; foreach($h in $hits){ Write-Output $h.FullName }]], + roots_arg, escaped +) + +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"')) +jarvis.log("info", "file_search query='" .. query .. "'") + +local res = jarvis.system.exec(cmd) + +if not res.success then + jarvis.log("error", "file_search exec failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() + return { chain = false } +end + +local stdout = res.stdout or "" +local lines = {} +for line in stdout:gmatch("[^\r\n]+") do + if line ~= "" then table.insert(lines, line) end +end + +if #lines == 0 then + jarvis.system.notify( + lang == "ru" and "Поиск файлов" or "File search", + (lang == "ru" and "Не найдено: " or "Not found: ") .. query + ) + jarvis.audio.play_not_found() +else + local first = lines[1] + local count = #lines + local title = lang == "ru" and "Найдено файлов: " or "Files found: " + jarvis.system.notify(title .. count, first) + + jarvis.log("info", "file_search opening: " .. first) + jarvis.system.exec(string.format('explorer.exe /select,"%s"', first)) + jarvis.audio.play_ok() +end + +return { chain = false } diff --git a/resources/commands/focus/command.toml b/resources/commands/focus/command.toml new file mode 100644 index 0000000..374afa0 --- /dev/null +++ b/resources/commands/focus/command.toml @@ -0,0 +1,52 @@ +# Focus mode — combines three things real Jarvis would do for a deep-work session: +# 1. switch active profile to "work" (mutes the noise commands) +# 2. enable Windows Focus Assist via PowerShell +# 3. schedule a break reminder for 50 minutes (with 10-min stretch reminder) +# All in one voice trigger. + +[[commands]] +id = "focus.start" +type = "lua" +script = "start.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "режим фокуса", + "включи фокус", + "сосредоточиться", + "не отвлекай", + "режим работы", + "хочу поработать", +] +en = [ + "focus mode", + "enable focus", + "do not disturb", + "deep work", + "let me focus", +] + + +[[commands]] +id = "focus.stop" +type = "lua" +script = "stop.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "выключи фокус", + "выключи режим фокуса", + "закончил работать", + "отключи не отвлекай", + "верни обычный режим", +] +en = [ + "exit focus", + "stop focus", + "disable focus", + "i'm done working", +] diff --git a/resources/commands/focus/start.lua b/resources/commands/focus/start.lua new file mode 100644 index 0000000..005d713 --- /dev/null +++ b/resources/commands/focus/start.lua @@ -0,0 +1,65 @@ +-- Enter focus mode — three side-effects bundled into one voice trigger: +-- 1. activate "work" profile (silences fun/games/banter commands) +-- 2. enable Windows Focus Assist (Quiet Hours) via registry tweak +-- 3. schedule a "стретчинг" reminder in 50 minutes via the persistent scheduler +-- +-- All three are independent — if one fails, the rest still happen. + +local lang = jarvis.context.language + +local report = {} + +-- 1) Profile switch (best effort) +local ok1, err1 = pcall(function() jarvis.profile.set("work") end) +if ok1 then + table.insert(report, lang == "ru" and "профиль 'работа'" or "work profile") +else + jarvis.log("warn", "focus.start: profile.set failed: " .. tostring(err1)) +end + +-- 2) Windows Focus Assist: writes the registry key Windows uses for +-- "Priority only" mode. This is the same key Windows 11 toggles when you +-- flip Focus on/off via the Action Center. +local ps = [[ +$key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings\Windows.SystemToast.QuietHours' +if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null } +Set-ItemProperty -Path $key -Name 'NOC_GLOBAL_SETTING_TOASTS_ENABLED' -Value 0 -Type DWord +Write-Output 'fa-on' +]] +local cmd = 'powershell -NoProfile -ExecutionPolicy Bypass -Command "& {' + .. ps:gsub('\r?\n', '; '):gsub('"', '\\"') + .. '}"' +local res = jarvis.system.exec(cmd) +if res.success and (res.stdout or ""):find("fa%-on") then + table.insert(report, lang == "ru" and "уведомления выключены" or "notifications muted") +else + jarvis.log("warn", "focus.start: focus assist registry tweak failed") +end + +-- 3) Stretch reminder in 50 minutes (the canonical Pomodoro-XL value) +local stretch_text = lang == "ru" + and "Сэр, 50 минут истекли. Встаньте, разомнитесь, посмотрите вдаль." + or "Sir, 50 minutes are up. Stand, stretch, look away from the screen." + +local ok3, err3 = pcall(function() + jarvis.scheduler.add({ + name = lang == "ru" and "Стретч-пауза" or "Stretch break", + schedule = "in 50 minutes", + action = { type = "speak", text = stretch_text }, + }) +end) +if ok3 then + table.insert(report, lang == "ru" and "пауза через 50 мин" or "break in 50 min") +else + jarvis.log("warn", "focus.start: scheduler.add failed: " .. tostring(err3)) +end + +if #report == 0 then + return jarvis.cmd.error(lang == "ru" and "Ничего не получилось включить." + or "Couldn't enable anything.") +end + +local summary = table.concat(report, ", ") +return jarvis.cmd.ok(lang == "ru" + and ("Включил режим фокуса: " .. summary .. ". Удачной работы, сэр.") + or ("Focus mode enabled: " .. summary .. ". Good luck, sir.")) diff --git a/resources/commands/focus/stop.lua b/resources/commands/focus/stop.lua new file mode 100644 index 0000000..a7663a0 --- /dev/null +++ b/resources/commands/focus/stop.lua @@ -0,0 +1,50 @@ +-- Leave focus mode — undo the three side-effects of focus.start. + +local lang = jarvis.context.language +local report = {} + +-- 1) Revert profile to default +local ok1 = pcall(function() jarvis.profile.set("default") end) +if ok1 then + table.insert(report, lang == "ru" and "обычный профиль" or "default profile") +end + +-- 2) Re-enable notifications via the same registry key +local ps = [[ +$key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Notifications\Settings\Windows.SystemToast.QuietHours' +if (Test-Path $key) { + Set-ItemProperty -Path $key -Name 'NOC_GLOBAL_SETTING_TOASTS_ENABLED' -Value 1 -Type DWord +} +Write-Output 'fa-off' +]] +local cmd = 'powershell -NoProfile -ExecutionPolicy Bypass -Command "& {' + .. ps:gsub('\r?\n', '; '):gsub('"', '\\"') + .. '}"' +local res = jarvis.system.exec(cmd) +if res.success then + table.insert(report, lang == "ru" and "уведомления включены" or "notifications restored") +end + +-- 3) Cancel any pending stretch reminders we created — match by name +-- (scheduler has no native "tags" so we use the human name we set in start.lua) +local cancelled = 0 +local target_names = { "Стретч-пауза", "Stretch break" } +for _, task in ipairs(jarvis.scheduler.list()) do + for _, name in ipairs(target_names) do + if task.name == name then + jarvis.scheduler.remove(task.id) + cancelled = cancelled + 1 + end + end +end +if cancelled > 0 then + table.insert(report, string.format(lang == "ru" + and "отменил %d напоминаний" + or "cancelled %d reminders", cancelled)) +end + +local summary = #report > 0 and table.concat(report, ", ") + or (lang == "ru" and "ничего не было активно" or "nothing was active") +return jarvis.cmd.ok(lang == "ru" + and ("Фокус выключен: " .. summary .. ".") + or ("Focus mode off: " .. summary .. ".")) diff --git a/resources/commands/fun/ask.lua b/resources/commands/fun/ask.lua new file mode 100644 index 0000000..9364270 --- /dev/null +++ b/resources/commands/fun/ask.lua @@ -0,0 +1,39 @@ +local lang = jarvis.context.language +local cmd_id = jarvis.context.command_id + +local prompts = { + joke = lang == "ru" + and "Расскажи одну короткую шутку на русском, 1-3 предложения. Без вступления и без 'вот шутка'. Просто шутка." + or "Tell one short joke in 1-3 sentences. No preamble.", + fact = lang == "ru" + and "Расскажи один интересный, неочевидный факт по любой теме. 1-3 предложения. Без 'вот факт'." + or "Tell one surprising fact in 1-3 sentences. No preamble.", + quote = lang == "ru" + and "Дай вдохновляющую цитату известного человека и автора. Без 'вот цитата'. Формат: <цитата> — <автор>." + or "Give one inspiring quote with author. Format: .", + compliment = lang == "ru" + and "Сделай искренний короткий комплимент пользователю, как британский дворецкий Джарвис. 1-2 предложения, обращение «сэр»." + or "Compliment the user briefly, in JARVIS-the-butler tone.", +} + +math.randomseed(os.time() + jarvis.context.time.second * 7919) + +local reply = jarvis.llm( + { + { role = "system", content = prompts[cmd_id] or prompts.joke }, + { role = "user", content = "seed " .. tostring(math.random(1, 9999)) }, + }, + { max_tokens = 220, temperature = 1.0 } +) + +if not reply or reply == "" then + jarvis.system.notify("Fun", lang == "ru" and "Не получилось" or "Failed") + jarvis.audio.play_error() + return { chain = false } +end + +local titles = { joke = "Анекдот", fact = "Факт", quote = "Цитата", compliment = "Комплимент" } +jarvis.system.notify(titles[cmd_id] or "Fun", reply:sub(1, 240)) +jarvis.speak(reply, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/fun/command.toml b/resources/commands/fun/command.toml new file mode 100644 index 0000000..da904ed --- /dev/null +++ b/resources/commands/fun/command.toml @@ -0,0 +1,46 @@ +[[commands]] +id = "joke" +type = "lua" +script = "ask.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = ["расскажи шутку", "пошути", "анекдот", "рассмеши меня"] +en = ["tell a joke", "make me laugh", "joke"] + + +[[commands]] +id = "fact" +type = "lua" +script = "ask.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = ["расскажи факт", "интересный факт", "поделись фактом"] +en = ["tell a fact", "interesting fact"] + + +[[commands]] +id = "quote" +type = "lua" +script = "ask.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = ["вдохнови меня", "цитата дня", "мотивируй", "процитируй кого-нибудь"] +en = ["inspire me", "quote of the day", "motivate me"] + + +[[commands]] +id = "compliment" +type = "lua" +script = "ask.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = ["сделай комплимент", "похвали меня"] +en = ["compliment me", "say something nice"] diff --git a/resources/commands/games/command.toml b/resources/commands/games/command.toml new file mode 100644 index 0000000..9a6854c --- /dev/null +++ b/resources/commands/games/command.toml @@ -0,0 +1,36 @@ +[[commands]] +id = "launch_game" +type = "lua" +script = "launch.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "запусти игру", + "включи игру", + "поиграем", + "хочу поиграть", + "запусти доту", + "включи кс", + "включи фортнайт", + "включи майнкрафт", +] +en = [ + "launch game", + "play game", + "launch dota", + "launch cs", +] + + +[[commands]] +id = "list_games" +type = "lua" +script = "list.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["какие у меня игры", "список игр", "покажи игры"] +en = ["list games", "show games"] diff --git a/resources/commands/games/launch.lua b/resources/commands/games/launch.lua new file mode 100644 index 0000000..1a03a3b --- /dev/null +++ b/resources/commands/games/launch.lua @@ -0,0 +1,110 @@ +local lang = jarvis.context.language +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local config_path = userprofile .. "\\Documents\\jarvis-games.json" + +if not jarvis.fs.exists(config_path) then + local sample = [[[ + {"name": "dota", "aliases": ["доту", "доту 2", "dota 2"], "steam_appid": 570}, + {"name": "cs2", "aliases": ["кс", "контру", "контр страйк"], "steam_appid": 730}, + {"name": "elden ring", "aliases": ["элден", "элден ринг"], "steam_appid": 1245620}, + {"name": "witcher 3", "aliases": ["ведьмак", "ведьмака 3"], "steam_appid": 292030}, + {"name": "factorio", "aliases": ["факторио"], "steam_appid": 427520}, + {"name": "minecraft", "aliases": ["майнкрафт", "майн"], "path": "C:\\Program Files (x86)\\Minecraft Launcher\\MinecraftLauncher.exe"}, + {"name": "fortnite", "aliases": ["фортнайт"], "epic_uri": "com.epicgames.launcher://apps/Fortnite?action=launch&silent=true"} +] +]] + jarvis.fs.write(config_path, sample) + jarvis.system.notify( + lang == "ru" and "Игры" or "Games", + lang == "ru" and "Создан шаблон ~/Documents/jarvis-games.json — добавь свои игры и повтори" + or "Template at Documents/jarvis-games.json — edit and retry" + ) + jarvis.system.open(config_path) + jarvis.audio.play_not_found() + return { chain = false } +end + +local content = jarvis.fs.read(config_path) +local entries = {} +local cursor = 1 +while true do + local s, e, block = content:find("({[^{}]+})", cursor) + if not s then break end + cursor = e + 1 + local name = block:match('"name"%s*:%s*"([^"]+)"') + if name then + local entry = { name = name, name_lower = name:lower(), aliases = {} } + for alias in block:gmatch('"([^"]+)"') do + entry.aliases[#entry.aliases + 1] = alias:lower() + end + entry.steam_appid = tonumber(block:match('"steam_appid"%s*:%s*(%d+)')) + entry.path = block:match('"path"%s*:%s*"([^"]+)"') + entry.epic_uri = block:match('"epic_uri"%s*:%s*"([^"]+)"') + table.insert(entries, entry) + end +end + +if #entries == 0 then + jarvis.system.notify("Games", lang == "ru" and "Пусто в конфиге" or "Empty config") + jarvis.audio.play_not_found() + return { chain = false } +end + +local phrase = (jarvis.context.phrase or ""):lower() +local triggers = { + "запусти игру", "включи игру", "хочу поиграть", "поиграем", "запусти", "включи", + "launch game", "play game", "launch", + "запусти гру", "увімкни гру", +} +local query = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(query, t, 1, true) + if s == 1 then + query = query:sub(e + 1) + break + end +end +query = query:gsub("^%s+", ""):gsub("%s+$", "") + +local function name_matches(e, q) + if e.name_lower == q then return true end + if string.find(e.name_lower, q, 1, true) then return true end + for _, a in ipairs(e.aliases) do + if a == q or string.find(q, a, 1, true) then return true end + end + return false +end + +local match +for _, e in ipairs(entries) do + if name_matches(e, query) then match = e; break end +end + +if not match then + jarvis.system.notify("Games", (lang == "ru" and "Не нашёл: " or "Not found: ") .. query) + jarvis.audio.play_not_found() + return { chain = false } +end + +local launched = false +if match.steam_appid then + jarvis.system.open("steam://rungameid/" .. tostring(match.steam_appid)) + launched = true +elseif match.epic_uri then + jarvis.system.open(match.epic_uri) + launched = true +elseif match.path and jarvis.fs.exists(match.path) then + jarvis.system.exec(string.format('start "" "%s"', match.path)) + launched = true +end + +if launched then + jarvis.system.notify(lang == "ru" and "Запускаю" or "Launching", match.name) + jarvis.speak((lang == "ru" and "Запускаю " or "Launching ") .. match.name .. ".", { lang = lang }) + jarvis.audio.play_ok() +else + jarvis.system.notify("Games", (lang == "ru" and "Не настроен запуск: " or "No launch info: ") .. match.name) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/games/list.lua b/resources/commands/games/list.lua new file mode 100644 index 0000000..37056dc --- /dev/null +++ b/resources/commands/games/list.lua @@ -0,0 +1,26 @@ +local lang = jarvis.context.language +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local config_path = userprofile .. "\\Documents\\jarvis-games.json" + +if not jarvis.fs.exists(config_path) then + jarvis.system.notify("Games", lang == "ru" + and "Конфиг не создан — скажи «запусти игру» один раз" or "No config yet") + jarvis.audio.play_not_found() + return { chain = false } +end + +local names = {} +for name in jarvis.fs.read(config_path):gmatch('"name"%s*:%s*"([^"]+)"') do + table.insert(names, name) +end +if #names == 0 then + jarvis.system.notify("Games", "Empty") + jarvis.audio.play_not_found() + return { chain = false } +end + +local body = table.concat(names, ", ") +jarvis.system.notify((lang == "ru" and "Игр: " or "Games: ") .. #names, body) +jarvis.speak((lang == "ru" and "В библиотеке: " or "Library: ") .. body, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/generators/coin.lua b/resources/commands/generators/coin.lua new file mode 100644 index 0000000..2115db2 --- /dev/null +++ b/resources/commands/generators/coin.lua @@ -0,0 +1,6 @@ +math.randomseed(os.time() + (jarvis.context.time.minute or 0) * 1000) +local r = math.random(1, 2) +if r == 1 then + return jarvis.cmd.ok("Орёл!") +end +return jarvis.cmd.ok("Решка!") diff --git a/resources/commands/generators/command.toml b/resources/commands/generators/command.toml new file mode 100644 index 0000000..6db2523 --- /dev/null +++ b/resources/commands/generators/command.toml @@ -0,0 +1,51 @@ +# Random generators — coin flip, password, secret token, color. + +[[commands]] +id = "gen.coin" +type = "lua" +script = "coin.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "подбрось монету", + "брось монетку", + "орёл или решка", + "монетка", +] +en = ["flip a coin", "coin flip", "heads or tails"] + + +[[commands]] +id = "gen.password" +type = "lua" +script = "password.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "сгенерируй пароль", + "придумай пароль", + "пароль 12 символов", + "сгенерируй надёжный пароль", +] +en = ["generate password", "make a password"] + + +[[commands]] +id = "gen.uuid" +type = "lua" +script = "uuid.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "сгенерируй uuid", + "сгенерируй юид", + "юид", + "уникальный идентификатор", +] +en = ["generate uuid", "uuid"] diff --git a/resources/commands/generators/password.lua b/resources/commands/generators/password.lua new file mode 100644 index 0000000..979b679 --- /dev/null +++ b/resources/commands/generators/password.lua @@ -0,0 +1,22 @@ +-- "Сгенерируй пароль 16 символов" → secure password (alphanumeric + symbols) +-- Copies result to clipboard, speaks length only (so it's not echoed). +local phrase = (jarvis.context.phrase or ""):lower() +local n = tonumber(phrase:match("(%d+)")) or 16 +if n < 6 then n = 6 end +if n > 64 then n = 64 end + +local pool = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_=+" +math.randomseed(os.time() + (jarvis.context.time.minute or 0) * 1000) + +local pw = {} +for _ = 1, n do + local idx = math.random(1, #pool) + table.insert(pw, pool:sub(idx, idx)) +end +local password = table.concat(pw) + +jarvis.system.clipboard.set(password) +jarvis.system.notify("Пароль (" .. n .. " символов)", "Скопирован в буфер обмена") + +-- Speak length only, never the password itself. +return jarvis.cmd.ok(string.format("Пароль на %d символов в буфере.", n)) diff --git a/resources/commands/generators/uuid.lua b/resources/commands/generators/uuid.lua new file mode 100644 index 0000000..5610d4d --- /dev/null +++ b/resources/commands/generators/uuid.lua @@ -0,0 +1,27 @@ +-- Generate v4 UUID, copy to clipboard, speak short suffix. +math.randomseed(os.time() + (jarvis.context.time.minute or 0) * 1000) + +local function hex(n) + local s = {} + for _ = 1, n do + s[#s + 1] = string.format("%x", math.random(0, 15)) + end + return table.concat(s) +end + +-- v4 UUID: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx where y is 8|9|a|b +local y_pool = { "8", "9", "a", "b" } +local uuid = string.format( + "%s-%s-4%s-%s%s-%s", + hex(8), hex(4), hex(3), + y_pool[math.random(1, 4)], hex(3), + hex(12) +) + +jarvis.system.clipboard.set(uuid) +jarvis.system.notify("UUID", uuid) + +-- Speak last 4 hex chars so user knows it landed but doesn't have to listen +-- to 32 characters. +local suffix = uuid:sub(-4) +return jarvis.cmd.ok("UUID в буфере, заканчивается на " .. suffix .. ".") diff --git a/resources/commands/github_issues/command.toml b/resources/commands/github_issues/command.toml new file mode 100644 index 0000000..3eca051 --- /dev/null +++ b/resources/commands/github_issues/command.toml @@ -0,0 +1,35 @@ +# GitHub issue search via gh CLI. + +[[commands]] +id = "github.list_issues" +type = "lua" +script = "list.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "какие issues", + "какие задачи", + "открытые issues", + "список issues", + "что в issues", + "тикеты", +] +en = ["list issues", "open issues", "what's in issues"] + + +[[commands]] +id = "github.my_issues" +type = "lua" +script = "mine.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "мои issues", + "мои задачи на гитхабе", + "что мне назначено", +] +en = ["my issues", "issues assigned to me"] diff --git a/resources/commands/github_issues/list.lua b/resources/commands/github_issues/list.lua new file mode 100644 index 0000000..5f57f6a --- /dev/null +++ b/resources/commands/github_issues/list.lua @@ -0,0 +1,34 @@ +local repo = jarvis.memory.recall("github.repo") +if not repo or repo == "" then + return jarvis.cmd.not_found("Сначала укажите репозиторий: текущий репо owner/repo.") +end + +local res = jarvis.system.exec(string.format( + 'gh issue list --repo "%s" --state open --json number,title,labels --limit 10', + repo +)) +if not res or not res.success then + return jarvis.cmd.error("gh CLI не отвечает.") +end + +local stdout = res.stdout or "" +if stdout:gsub("%s", "") == "" or stdout:gsub("%s", "") == "[]" then + return jarvis.cmd.ok("Открытых issues нет.") +end + +local _, count = stdout:gsub('"number"%s*:', "") +if count == 0 then + return jarvis.cmd.ok("Открытых issues нет.") +end + +local titles = {} +for title in stdout:gmatch('"title"%s*:%s*"([^"]+)"') do + table.insert(titles, title) + if #titles >= 3 then break end +end + +local line = string.format("%d открытых issues. ", count) +if #titles > 0 then + line = line .. "Первые: " .. table.concat(titles, ". ") .. "." +end +return jarvis.cmd.ok(line) diff --git a/resources/commands/github_issues/mine.lua b/resources/commands/github_issues/mine.lua new file mode 100644 index 0000000..7c9478c --- /dev/null +++ b/resources/commands/github_issues/mine.lua @@ -0,0 +1,25 @@ +-- Issues assigned to the authenticated user across all their repos. +local res = jarvis.system.exec( + 'gh issue list --assignee @me --state open --json number,title,repository --limit 10' +) +if not res or not res.success then + return jarvis.cmd.error("gh CLI не отвечает.") +end + +local stdout = res.stdout or "" +local _, count = stdout:gsub('"number"%s*:', "") +if count == 0 then + return jarvis.cmd.ok("Тебе ничего не назначено.") +end + +local titles = {} +for title in stdout:gmatch('"title"%s*:%s*"([^"]+)"') do + table.insert(titles, title) + if #titles >= 3 then break end +end + +local line = string.format("На вас %d issues. ", count) +if #titles > 0 then + line = line .. "Первые: " .. table.concat(titles, ". ") .. "." +end +return jarvis.cmd.ok(line) diff --git a/resources/commands/github_pr/command.toml b/resources/commands/github_pr/command.toml new file mode 100644 index 0000000..4819584 --- /dev/null +++ b/resources/commands/github_pr/command.toml @@ -0,0 +1,65 @@ +# GitHub PR voice review — list open PRs and summarise the latest one via LLM. +# Requires `gh` CLI installed and authenticated (`gh auth login`). +# Repo can be set via voice ("текущий репо ") or auto-detected +# from cwd if jarvis-app is launched inside a git repo. + +[[commands]] +id = "github.list_prs" +type = "lua" +script = "list.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "какие пиары", + "какие пулл реквесты", + "что в пиарах", + "что в pr", + "открытые пиары", + "список пиаров", +] +en = [ + "list open prs", + "what's in the prs", + "open pull requests", +] + + +[[commands]] +id = "github.summarize_pr" +type = "lua" +script = "summarize.lua" +sandbox = "full" +timeout = 60000 + +[commands.phrases] +ru = [ + "разбери последний пиар", + "объясни последний пиар", + "обзор последнего пиара", + "что в последнем pr", + "разбор pr", +] +en = [ + "review the latest pr", + "explain the latest pr", + "summarize pr", +] + + +[[commands]] +id = "github.set_repo" +type = "lua" +script = "set_repo.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "текущий репо", + "установи репо", + "выбери репо", + "репозиторий", +] +en = ["set repo", "current repo"] diff --git a/resources/commands/github_pr/list.lua b/resources/commands/github_pr/list.lua new file mode 100644 index 0000000..f9bb4fc --- /dev/null +++ b/resources/commands/github_pr/list.lua @@ -0,0 +1,39 @@ +local repo = jarvis.memory.recall("github.repo") +if not repo or repo == "" then + return jarvis.cmd.not_found("Сначала укажите репозиторий: текущий репо owner/repo.") +end + +local result = jarvis.system.exec(string.format( + 'gh pr list --repo "%s" --state open --json number,title,author,createdAt --limit 10', + repo +)) +if not result or not result.success then + local err = (result and result.stderr) or "gh CLI не доступен" + jarvis.log("warn", "gh failed: " .. err) + return jarvis.cmd.error("gh CLI не отвечает. Установите gh и выполните gh auth login.") +end + +local stdout = result.stdout or "" +if stdout:gsub("%s", "") == "" or stdout:gsub("%s", "") == "[]" then + return jarvis.cmd.ok("Открытых пиаров нет.") +end + +-- Parse JSON via LLM (mlua doesn't ship a JSON parser by default). +-- Alternatively count number entries with a regex. +local _, count = stdout:gsub('"number"%s*:', "") +if count == 0 then + return jarvis.cmd.ok("Открытых пиаров нет.") +end + +-- Get titles +local titles = {} +for title in stdout:gmatch('"title"%s*:%s*"([^"]+)"') do + table.insert(titles, title) + if #titles >= 3 then break end +end + +local line = string.format("%d открытых пиаров. ", count) +if #titles > 0 then + line = line .. "Первые: " .. table.concat(titles, ". ") .. "." +end +return jarvis.cmd.ok(line) diff --git a/resources/commands/github_pr/set_repo.lua b/resources/commands/github_pr/set_repo.lua new file mode 100644 index 0000000..1c3c8a7 --- /dev/null +++ b/resources/commands/github_pr/set_repo.lua @@ -0,0 +1,27 @@ +-- "Текущий репо bossiara13/J.A.R.V.I.S-rust" +local phrase = (jarvis.context.phrase or "") +local repo = jarvis.text.strip_trigger(phrase:lower(), { + "установи репо", + "выбери репо", + "текущий репо", + "репозиторий", + "set repo", + "current repo", + "встанови репо", +}) +repo = repo:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- preserve original casing +if repo ~= "" then + local start_idx = phrase:lower():find(repo, 1, true) + if start_idx then + repo = phrase:sub(start_idx, start_idx + #repo - 1) + end +end + +if repo == "" or not repo:find("/") then + return jarvis.cmd.error("Скажите owner слеш repo, например bossiara13 слеш J A R V I S.") +end + +jarvis.memory.remember("github.repo", repo) +return jarvis.cmd.ok("Репозиторий " .. repo .. " установлен.") diff --git a/resources/commands/github_pr/summarize.lua b/resources/commands/github_pr/summarize.lua new file mode 100644 index 0000000..f2b3be8 --- /dev/null +++ b/resources/commands/github_pr/summarize.lua @@ -0,0 +1,62 @@ +local repo = jarvis.memory.recall("github.repo") +if not repo or repo == "" then + return jarvis.cmd.not_found("Сначала укажите репозиторий: текущий репо owner/repo.") +end + +-- get the most recent PR's number +local list = jarvis.system.exec(string.format( + 'gh pr list --repo "%s" --state open --json number --limit 1', + repo +)) +if not list or not list.success then + return jarvis.cmd.error("gh CLI не отвечает.") +end + +local number = (list.stdout or ""):match('"number"%s*:%s*(%d+)') +if not number then + return jarvis.cmd.ok("Открытых пиаров нет.") +end + +-- fetch title + body + diff stat +local detail = jarvis.system.exec(string.format( + 'gh pr view %s --repo "%s" --json title,body,additions,deletions,changedFiles,author', + number, repo +)) +if not detail or not detail.success then + return jarvis.cmd.error("Не получилось получить PR.") +end + +local d = detail.stdout or "" +local title = d:match('"title"%s*:%s*"([^"]*)"') or "(no title)" +local body = d:match('"body"%s*:%s*"(.-)"') or "" +body = body:gsub("\\n", "\n"):gsub("\\r", " "):gsub('\\"', '"'):gsub("\\\\", "\\") +local additions = d:match('"additions"%s*:%s*(%d+)') or "?" +local deletions = d:match('"deletions"%s*:%s*(%d+)') or "?" +local files = d:match('"changedFiles"%s*:%s*(%d+)') or "?" +local author = d:match('"login"%s*:%s*"([^"]*)"') or "(unknown)" + +-- Trim body for prompt +if #body > 3000 then body = body:sub(1, 3000) .. "\n... [truncated]" end + +local user_prompt = string.format([[ +Repo: %s +PR #%s: %s +Author: %s +Changed files: %s (+%s, -%s) +Description: +%s + +Кратко (3-5 предложений) на русском объясни: что меняет этот PR, какие риски, стоит ли мёржить. +]], repo, number, title, author, files, additions, deletions, body) + +local review = jarvis.llm({ + { role = "system", content = "Ты — старший разработчик, делаешь код-ревью. Отвечай по делу, без воды, без 'отличный PR' вступлений." }, + { role = "user", content = user_prompt } +}, { max_tokens = 400, temperature = 0.2 }) + +if not review or review == "" then + return jarvis.cmd.error("Не получилось проанализировать.") +end + +jarvis.system.notify(string.format("PR #%s — %s", number, title), review:sub(1, 250)) +return jarvis.cmd.ok(string.format("Пиар номер %s: %s", number, review)) diff --git a/resources/commands/habit_nudge/command.toml b/resources/commands/habit_nudge/command.toml new file mode 100644 index 0000000..cfbd884 --- /dev/null +++ b/resources/commands/habit_nudge/command.toml @@ -0,0 +1,65 @@ +# Habit nudges — convenient wrappers for popular recurring reminders. + +[[commands]] +id = "habit.water" +type = "lua" +script = "water.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "напоминай мне пить воду", + "напоминай попить воду", + "включи напоминание про воду", +] +en = ["remind me to drink water", "water reminder"] + + +[[commands]] +id = "habit.stretch" +type = "lua" +script = "stretch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "напоминай мне размяться", + "напоминай делать зарядку", + "включи напоминание про разминку", + "напоминай вставать", +] +en = ["remind me to stretch", "stretch reminder"] + + +[[commands]] +id = "habit.eyes" +type = "lua" +script = "eyes.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "напоминай отдыхать глазам", + "напоминай про глаза", + "20 20 20", +] +en = ["remind me to rest eyes", "eye reminder"] + + +[[commands]] +id = "habit.stop_all" +type = "lua" +script = "stop_all.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "отключи все привычки", + "хватит напоминать про привычки", + "выключи все напоминалки", +] +en = ["stop all habits", "disable habit reminders"] diff --git a/resources/commands/habit_nudge/eyes.lua b/resources/commands/habit_nudge/eyes.lua new file mode 100644 index 0000000..5b9d708 --- /dev/null +++ b/resources/commands/habit_nudge/eyes.lua @@ -0,0 +1,18 @@ +-- 20-20-20 rule: every 20 min look 20ft away for 20s. +jarvis.scheduler.remove("habit_eyes") +local ok, err = pcall(function() + jarvis.scheduler.add({ + id = "habit_eyes", + name = "20-20-20 eye rest", + schedule = "every 20 minutes", + action = { type = "speak", text = "Отвлекитесь от экрана на двадцать секунд." } + }) +end) +if not ok then + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end +jarvis.speak("Включил правило двадцать двадцать двадцать.") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/habit_nudge/stop_all.lua b/resources/commands/habit_nudge/stop_all.lua new file mode 100644 index 0000000..b8a4565 --- /dev/null +++ b/resources/commands/habit_nudge/stop_all.lua @@ -0,0 +1,11 @@ +local removed = 0 +for _, id in ipairs({ "habit_water", "habit_stretch", "habit_eyes" }) do + if jarvis.scheduler.remove(id) then + removed = removed + 1 + end +end + +if removed > 0 then + return jarvis.cmd.ok("Отключил привычек: " .. removed .. ".") +end +return jarvis.cmd.not_found("Привычки и так не запущены.") diff --git a/resources/commands/habit_nudge/stretch.lua b/resources/commands/habit_nudge/stretch.lua new file mode 100644 index 0000000..5748409 --- /dev/null +++ b/resources/commands/habit_nudge/stretch.lua @@ -0,0 +1,17 @@ +jarvis.scheduler.remove("habit_stretch") +local ok, err = pcall(function() + jarvis.scheduler.add({ + id = "habit_stretch", + name = "Stretch break", + schedule = "every 50 minutes", + action = { type = "speak", text = "Время размяться. Встаньте, пройдитесь." } + }) +end) +if not ok then + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end +jarvis.speak("Каждые 50 минут буду напоминать размяться.") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/habit_nudge/water.lua b/resources/commands/habit_nudge/water.lua new file mode 100644 index 0000000..bc85058 --- /dev/null +++ b/resources/commands/habit_nudge/water.lua @@ -0,0 +1,17 @@ +jarvis.scheduler.remove("habit_water") +local ok, err = pcall(function() + jarvis.scheduler.add({ + id = "habit_water", + name = "Drink water", + schedule = "every 2 hours", + action = { type = "speak", text = "Попей воды." } + }) +end) +if not ok then + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end +jarvis.speak("Каждые 2 часа буду напоминать пить воду.") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/habit_streaks/checkin.lua b/resources/commands/habit_streaks/checkin.lua new file mode 100644 index 0000000..799a87b --- /dev/null +++ b/resources/commands/habit_streaks/checkin.lua @@ -0,0 +1,24 @@ +-- Mark a habit done today. +local phrase = (jarvis.context.phrase or ""):lower() + +local habit = nil +if phrase:find("воды") or phrase:find("воду") then habit = "water" +elseif phrase:find("размял") or phrase:find("зарядк") then habit = "stretch" +elseif phrase:find("глаз") then habit = "eyes" +end + +-- Fallback: pick first word after "выполнил"/"отметь" +if not habit then + habit = jarvis.text.strip_trigger(phrase, { + "отметь привычку", "выполнил привычку", "выполнил", "отметь", + "check in habit", + }):gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + if habit == "" then habit = "general" end +end + +local t = jarvis.context.time +local date_str = string.format("%04d-%02d-%02d", t.year, t.month, t.day) +local key = "habit_streak." .. habit .. "." .. date_str + +jarvis.memory.remember(key, "1") +return jarvis.cmd.ok("Отметил " .. habit .. " за сегодня.") diff --git a/resources/commands/habit_streaks/command.toml b/resources/commands/habit_streaks/command.toml new file mode 100644 index 0000000..6710ae5 --- /dev/null +++ b/resources/commands/habit_streaks/command.toml @@ -0,0 +1,39 @@ +# Habit streaks — track when habit_nudge tasks actually fire, view stats. +# Producers (habit_nudge schedule firings) should call jarvis.memory.remember +# "habit_streak.." = "1" on each firing. + +[[commands]] +id = "habits.streak" +type = "lua" +script = "streak.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "сколько дней подряд", + "мои привычки", + "статистика привычек", + "статистика по привычкам", + "сколько дней пью воду", + "трекинг привычек", +] +en = ["habit streaks", "my habits stats"] + + +[[commands]] +id = "habits.checkin" +type = "lua" +script = "checkin.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "отметь привычку", + "выполнил привычку", + "я попил воды", + "я размялся", + "я отдохнул глазам", +] +en = ["check in habit", "I did the habit"] diff --git a/resources/commands/habit_streaks/streak.lua b/resources/commands/habit_streaks/streak.lua new file mode 100644 index 0000000..5a2ac2a --- /dev/null +++ b/resources/commands/habit_streaks/streak.lua @@ -0,0 +1,66 @@ +-- Reads memory keys "habit_streak.." and computes streak per habit. +local t = jarvis.context.time + +local function date_offset(days_back) + -- Simple naive backwards calc (no calendar arithmetic — close enough for streaks) + local jd_today = t.year * 365 + math.floor(t.year / 4) + + ({0,31,59,90,120,151,181,212,243,273,304,334})[t.month] + + (t.day - 1) + local target_jd = jd_today - days_back + -- Convert back to YYYY-MM-DD (approximate) + local y = math.floor(target_jd / 365.25) + local doy = target_jd - math.floor(y * 365.25) + local month_starts = {0,31,59,90,120,151,181,212,243,273,304,334} + local mo = 12 + for i = 12, 1, -1 do + if doy >= month_starts[i] then mo = i; doy = doy - month_starts[i]; break end + end + return string.format("%04d-%02d-%02d", y, mo, doy + 1) +end + +local all = jarvis.memory.all() + +-- Group keys by habit name +local by_habit = {} +for _, rec in ipairs(all) do + local habit, date = rec.key:match("^habit_streak%.([^%.]+)%.(.+)$") + if habit and date then + by_habit[habit] = by_habit[habit] or {} + by_habit[habit][date] = true + end +end + +if next(by_habit) == nil then + return jarvis.cmd.not_found("Привычек ещё не отмечено. Скажи 'выполнил привычку' после события.") +end + +-- For each habit, count consecutive days back from today +local results = {} +for habit, dates in pairs(by_habit) do + local streak = 0 + for d = 0, 30 do + local key_date = date_offset(d) + if dates[key_date] then + streak = streak + 1 + else + break + end + end + table.insert(results, { habit = habit, streak = streak }) +end + +table.sort(results, function(a, b) return a.streak > b.streak end) + +local sample = math.min(#results, 3) +local lines = {} +for i = 1, sample do + local r = results[i] + if r.streak == 0 then + table.insert(lines, r.habit .. " прервана") + else + local d = r.streak == 1 and "день" or (r.streak < 5 and "дня" or "дней") + table.insert(lines, r.habit .. " " .. r.streak .. " " .. d) + end +end + +return jarvis.cmd.ok("Стрики: " .. table.concat(lines, ", ") .. ".") diff --git a/resources/commands/help/command.toml b/resources/commands/help/command.toml new file mode 100644 index 0000000..4645336 --- /dev/null +++ b/resources/commands/help/command.toml @@ -0,0 +1,22 @@ +[[commands]] +id = "help" +type = "lua" +script = "help.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "что ты умеешь", + "какие команды", + "список команд", + "помощь", + "помоги", + "что можешь", +] +en = [ + "what can you do", + "list commands", + "help", + "show commands", +] diff --git a/resources/commands/help/help.lua b/resources/commands/help/help.lua new file mode 100644 index 0000000..3bcac7c --- /dev/null +++ b/resources/commands/help/help.lua @@ -0,0 +1,75 @@ +local lang = jarvis.context.language + +local commands_dir = jarvis.context.command_path:match("^(.*[/\\])[^/\\]+[/\\]?$") +if not commands_dir then + jarvis.audio.play_error() + return { chain = false } +end + +local entries = jarvis.fs.list(commands_dir) +local packs = {} +for _, e in ipairs(entries) do + if e.is_dir then table.insert(packs, e.name) end +end +table.sort(packs) + +local descriptions = { + apps = lang == "ru" and "браузер, блокнот, калькулятор, проводник, терминал, настройки, диспетчер задач, блокировка, скриншот" or "browser, notepad, calculator, explorer, terminal, settings, taskmgr, lock, screenshot", + volume = lang == "ru" and "громче, тише, mute, максимум" or "louder, quieter, mute, max", + media = lang == "ru" and "пауза, play, следующий/предыдущий трек, стоп" or "pause/play, next/prev track, stop", + file_search = lang == "ru" and "найди файл X" or "find file X", + output_device = lang == "ru" and "список устройств вывода, переключи" or "list/cycle audio out", + sysinfo = lang == "ru" and "время, заряд, CPU, RAM, диск, статус системы" or "time, battery, cpu, ram, disk", + window = lang == "ru" and "сверни/разверни/закрой окно, snap влево/вправо, рабочий стол" or "min/max/close window, snap, desktop", + clipboard_read = lang == "ru" and "прочитай буфер" or "read clipboard", + notes = lang == "ru" and "запиши/покажи заметки" or "write/show notes", + process_kill = lang == "ru" and "закрой программу X" or "kill process X", + websearch = lang == "ru" and "загугли / ютуб / вики / яндекс" or "search google/youtube/wiki/yandex", + translate = lang == "ru" and "переведи на X" or "translate to X", + math = lang == "ru" and "посчитай X" or "calculate X", + theme = lang == "ru" and "тёмная/светлая тема" or "dark/light theme", + projects = lang == "ru" and "открой/список проектов" or "open/list projects", + sound_panel = lang == "ru" and "панель звука" or "sound panel", + codegen = lang == "ru" and "напиши код X" or "write code X", + ocr = lang == "ru" and "прочитай экран" or "read screen", + reminders = lang == "ru" and "напомни через N минут X" or "remind me in N min X", + date_query = lang == "ru" and "что сегодня / завтра / вчера" or "today/tomorrow/yesterday", + voice_type = lang == "ru" and "напечатай X" or "type X", + dice = lang == "ru" and "монетка, кубик, случайное число" or "coin, dice, random", + stopwatch = lang == "ru" and "засеки время / стоп секундомер" or "start/stop stopwatch", + news = lang == "ru" and "что нового" or "what's new", + currency = lang == "ru" and "курс доллара / евро / юаня" or "USD/EUR/CNY rate", + crypto = lang == "ru" and "цена биткоина / эфира" or "BTC/ETH price", + fun = lang == "ru" and "шутка, факт, цитата, комплимент" or "joke, fact, quote, compliment", + wiki = lang == "ru" and "что такое / кто такой X" or "what/who is X", + power = lang == "ru" and "выключи / перезагрузи / усыпи / гибернация" or "shutdown / restart / sleep / hibernate", + weather = lang == "ru" and "какая погода" or "what's the weather", + counter = lang == "ru" and "счётчик" or "counter", + help = lang == "ru" and "что ты умеешь" or "what can you do", +} + +local lines = {} +for _, p in ipairs(packs) do + local desc = descriptions[p] or "" + if desc ~= "" then + table.insert(lines, "• " .. p .. ": " .. desc) + end +end + +local title = string.format(lang == "ru" and "Команды (%d пакетов)" or "Commands (%d packs)", #packs) +local body = table.concat(lines, "\n") +jarvis.system.clipboard.set(body) +jarvis.system.notify(title, body:sub(1, 800)) + +local say = lang == "ru" + and string.format("Загружено %d пакетов команд. Полный список скопирован в буфер.", #packs) + or string.format("Loaded %d command packs. Full list is on the clipboard.", #packs) +local sapi = say:gsub("'", "''") +local ps = string.format( + [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], + sapi +) +jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) + +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/ics_event/command.toml b/resources/commands/ics_event/command.toml new file mode 100644 index 0000000..ebc479b --- /dev/null +++ b/resources/commands/ics_event/command.toml @@ -0,0 +1,18 @@ +# Create calendar event via .ics file. Opens in default handler (Outlook/Mail). + +[[commands]] +id = "ics.create" +type = "lua" +script = "create.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "добавь встречу", + "создай событие", + "создай встречу", + "запиши встречу", + "новая встреча", +] +en = ["add meeting", "create event"] diff --git a/resources/commands/ics_event/create.lua b/resources/commands/ics_event/create.lua new file mode 100644 index 0000000..22c6181 --- /dev/null +++ b/resources/commands/ics_event/create.lua @@ -0,0 +1,77 @@ +-- "Добавь встречу завтра в 15:00 обсудить проект" +-- Heuristic parsing: extracts time + body, defaults to tomorrow. +local phrase = (jarvis.context.phrase or "") +local body = jarvis.text.strip_trigger(phrase:lower(), { + "добавь встречу", + "создай событие", + "создай встречу", + "запиши встречу", + "новая встреча", + "add meeting", + "create event", +}) +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + return jarvis.cmd.error("Опишите встречу.") +end + +-- Find time HH:MM or "в HH" +local h, m = body:match("(%d%d?)[:%-%s](%d%d)") +if not h then + h = body:match("в%s+(%d%d?)") + m = "00" +end +local hh = tonumber(h) or 9 +local mm = tonumber(m) or 0 + +-- Date: "сегодня" / "завтра" / default to tomorrow +local today = jarvis.context.time +local year, month, day = today.year, today.month, today.day +if body:find("сегодня") then + -- leave as-is +elseif body:find("послезавтра") then + day = day + 2 +else + -- default tomorrow + day = day + 1 +end + +-- ICS uses UTC if Z suffix or local with TZID. We'll use local naive. +local dt_start = string.format("%04d%02d%02dT%02d%02d00", year, month, day, hh, mm) +local dt_end = string.format("%04d%02d%02dT%02d%02d00", year, month, day, hh + 1, mm) +local now_stamp = string.format("%04d%02d%02dT%02d%02d00", + today.year, today.month, today.day, today.hour, today.minute) + +-- Clean summary (strip the time tokens) +local summary = body:gsub("%d%d?[:%-%s]%d%d", ""):gsub("в%s+%d%d?", "") + :gsub("сегодня", ""):gsub("послезавтра", ""):gsub("завтра", "") + :gsub("^[%s,]+", ""):gsub("%s+$", "") +if summary == "" then summary = "Встреча" end + +local uid = string.format("jarvis-%s-%d", now_stamp, math.random(1000, 9999)) +local ics = string.format( +[[BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//J.A.R.V.I.S.//EN +BEGIN:VEVENT +UID:%s +DTSTAMP:%s +DTSTART:%s +DTEND:%s +SUMMARY:%s +END:VEVENT +END:VCALENDAR]], + uid, now_stamp, dt_start, dt_end, summary +) + +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local dir = userprofile .. "\\Documents\\jarvis-events" +jarvis.fs.mkdir(dir) +local path = dir .. "\\event-" .. now_stamp .. ".ics" +jarvis.fs.write(path, ics) + +-- Open via default handler (Outlook / Mail). +jarvis.system.open(path) + +return jarvis.cmd.ok(string.format("Встреча создана: %s в %02d:%02d.", summary, hh, mm)) diff --git a/resources/commands/interesting_fact/command.toml b/resources/commands/interesting_fact/command.toml new file mode 100644 index 0000000..05fa7c8 --- /dev/null +++ b/resources/commands/interesting_fact/command.toml @@ -0,0 +1,36 @@ +# Interesting fact about X via LLM. + +[[commands]] +id = "fact.about" +type = "lua" +script = "fact.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "расскажи интересный факт", + "интересный факт о", + "интересный факт про", + "удиви меня", + "интересный факт", + "расскажи факт", +] +en = ["tell me an interesting fact", "fun fact about", "surprise me"] + + +[[commands]] +id = "fact.history_today" +type = "lua" +script = "history_today.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "что было в этот день", + "что произошло в этот день", + "этот день в истории", + "что случилось", +] +en = ["this day in history", "what happened today"] diff --git a/resources/commands/interesting_fact/fact.lua b/resources/commands/interesting_fact/fact.lua new file mode 100644 index 0000000..b923d57 --- /dev/null +++ b/resources/commands/interesting_fact/fact.lua @@ -0,0 +1,35 @@ +-- "Интересный факт о космосе" / "удиви меня" +local phrase = (jarvis.context.phrase or ""):lower() +local topic = jarvis.text.strip_trigger(phrase, { + "расскажи интересный факт про", + "расскажи интересный факт о", + "интересный факт про", + "интересный факт о", + "расскажи интересный факт", + "интересный факт", + "расскажи факт", + "удиви меня", + "tell me an interesting fact", + "fun fact about", + "surprise me", + "цікавий факт", +}) +topic = topic:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +local user_prompt +if topic == "" then + user_prompt = "Расскажи один реально неочевидный научно-проверенный факт. На русском, 1-2 предложения. Без вступлений типа 'знали ли вы'." +else + user_prompt = "Расскажи один интересный факт про " .. topic .. ". На русском, 1-2 предложения. Без вступлений." +end + +local reply = jarvis.llm({ + { role = "system", content = "Ты любопытный собеседник. Говоришь короткими, цепляющими фактами без воды." }, + { role = "user", content = user_prompt } +}, { max_tokens = 200, temperature = 0.8 }) + +if not reply or reply == "" then + return jarvis.cmd.error("Сегодня нет интересных фактов.") +end + +return jarvis.cmd.ok(reply) diff --git a/resources/commands/interesting_fact/history_today.lua b/resources/commands/interesting_fact/history_today.lua new file mode 100644 index 0000000..22a1420 --- /dev/null +++ b/resources/commands/interesting_fact/history_today.lua @@ -0,0 +1,48 @@ +-- "Что было в этот день в истории" — Wikipedia API + LLM сжатие. +local t = jarvis.context.time + +-- Wikipedia's "On this day" Featured Content API: +-- https://api.wikimedia.org/feed/v1/wikipedia/ru/onthisday/events/MM/DD +local url = string.format( + "https://api.wikimedia.org/feed/v1/wikipedia/ru/onthisday/events/%02d/%02d", + t.month, t.day +) + +local data = jarvis.http.json(url) +if not data or not data.events or #data.events == 0 then + -- fallback: EN + url = string.format( + "https://api.wikimedia.org/feed/v1/wikipedia/en/onthisday/events/%02d/%02d", + t.month, t.day + ) + data = jarvis.http.json(url) +end + +if not data or not data.events or #data.events == 0 then + return jarvis.cmd.not_found("На этот день в истории ничего интересного.") +end + +-- Pick first 5 events to feed into LLM for summarisation +local sample = {} +for i = 1, math.min(5, #data.events) do + local ev = data.events[i] + local year = ev.year or "?" + local text = ev.text or "" + if text ~= "" then + table.insert(sample, year .. ": " .. text) + end +end + +local context = table.concat(sample, "\n") + +local reply = jarvis.llm({ + { role = "system", content = "Ты — историк. Из списка событий выбери 2-3 самых интересных для русскоязычного слушателя, расскажи коротко на русском. Без преамбул." }, + { role = "user", content = "Сегодня " .. t.day .. " число месяца " .. t.month .. ". События в этот день:\n\n" .. context } +}, { max_tokens = 350, temperature = 0.4 }) + +if not reply or reply == "" then + -- Fallback: just speak first event + return jarvis.cmd.ok(sample[1] or "Не нашёл интересного.") +end + +return jarvis.cmd.ok(reply) diff --git a/resources/commands/intro/about.lua b/resources/commands/intro/about.lua new file mode 100644 index 0000000..79f48e7 --- /dev/null +++ b/resources/commands/intro/about.lua @@ -0,0 +1,20 @@ +-- Short bio. Falls back to LLM if available for variation, otherwise canned text. +local prof = jarvis.profile.active() +local prof_part = "" +if prof and prof.name and prof.name ~= "default" then + prof_part = " Сейчас работаю в режиме " .. prof.name .. "." +end + +local canned = "Я Джарвис, локальный голосовой ассистент на Rust." .. + " Работаю на вашем компьютере, могу использовать облачный или локальный мозг." .. + prof_part .. + " Спросите 'что ты умеешь' для обзора возможностей." + +-- If LLM is configured, give a tiny variation. Otherwise use canned. +local llm_text = jarvis.llm({ + { role = "system", content = "Ты — Джарвис из вселенной Marvel, британский дворецкий Тони Старка. Одна короткая фраза (1-2 предложения) на русском, представь себя пользователю. Без слов 'я искусственный интеллект' и подобных." }, + { role = "user", content = "Расскажи о себе одной фразой." } +}, { max_tokens = 80, temperature = 0.8 }) + +local msg = (llm_text and llm_text ~= "") and llm_text or canned +return jarvis.cmd.ok(msg) diff --git a/resources/commands/intro/command.toml b/resources/commands/intro/command.toml new file mode 100644 index 0000000..2b7fb66 --- /dev/null +++ b/resources/commands/intro/command.toml @@ -0,0 +1,57 @@ +# Voice-driven intro / capability discovery. +# Speaks a category-grouped overview of what Jarvis can do, scoped to the +# currently active profile (if the profile has allow/deny lists). + +[[commands]] +id = "intro.capabilities" +type = "lua" +script = "what_can_you_do.lua" +sandbox = "minimal" +timeout = 5000 + +[commands.phrases] +ru = [ + "что ты умеешь", + "что ты можешь", + "какие у тебя возможности", + "что я могу попросить", + "помощь", + "помоги", + "как тобой пользоваться", + "научи меня", +] +en = ["what can you do", "help", "capabilities", "how do I use you"] + + +[[commands]] +id = "intro.about" +type = "lua" +script = "about.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "расскажи о себе", + "кто ты такой", + "что ты за ассистент", + "что ты вообще", + "о тебе", +] +en = ["who are you", "tell me about yourself", "about you"] + + +[[commands]] +id = "intro.commands_count" +type = "lua" +script = "count.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "сколько команд знаешь", + "сколько у тебя команд", + "сколько ты умеешь команд", +] +en = ["how many commands", "command count"] diff --git a/resources/commands/intro/count.lua b/resources/commands/intro/count.lua new file mode 100644 index 0000000..788c890 --- /dev/null +++ b/resources/commands/intro/count.lua @@ -0,0 +1,10 @@ +-- "Сколько команд знаешь" — выводит health snapshot для подтверждения готовности. +local h = jarvis.health() +local memory_count = h.memory_facts or 0 +local sched_count = h.scheduled_tasks or 0 + +local msg = "Активных бекендов: TTS " .. (h.tts_backend or "—") .. + ", LLM " .. (h.llm_backend or "—") .. + ". В памяти " .. memory_count .. " фактов, в расписании " .. sched_count .. " задач." + +return jarvis.cmd.ok(msg) diff --git a/resources/commands/intro/what_can_you_do.lua b/resources/commands/intro/what_can_you_do.lua new file mode 100644 index 0000000..671b890 --- /dev/null +++ b/resources/commands/intro/what_can_you_do.lua @@ -0,0 +1,20 @@ +-- "Что ты умеешь" → говорит обзор возможностей по категориям. +-- Категории захардкожены — переводы команд в реестре делать дорого, +-- проще держать рукописный summary. + +local lines = { + "Я могу управлять компьютером голосом.", + "По темам: звук и медиа, окна и приложения, поиск файлов, заметки и буфер.", + "Также: переводы, конвертация валют, котировки акций, википедия и поиск в гугле.", + "Умею ставить напоминания, помодоро и привычки.", + "Запоминаю факты о вас, имею профили работы и игр, могу записывать макросы команд.", + "Если не понимаю команду, спрашиваю облачный или локальный мозг. Спросите 'какой у тебя мозг'.", + "Скажите 'что в расписании' чтобы посмотреть задачи, или откройте окно для полного списка.", +} + +for _, line in ipairs(lines) do + jarvis.speak(line) + jarvis.sleep(120) +end + +return jarvis.cmd.silent_ok() diff --git a/resources/commands/leftoff/command.toml b/resources/commands/leftoff/command.toml new file mode 100644 index 0000000..9174934 --- /dev/null +++ b/resources/commands/leftoff/command.toml @@ -0,0 +1,27 @@ +# "Where did I leave off" — pull recent context from across the assistant's +# state (last conversation, latest memory facts, last tracker session, latest +# scheduled task) and read out a one-paragraph recap. Nice for picking up after +# a coffee break or returning to the PC. + +[[commands]] +id = "leftoff.recap" +type = "lua" +script = "recap.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "на чём я остановился", + "на чем я остановился", + "напомни что делал", + "что я делал", + "где я остановился", + "продолжи откуда я", +] +en = [ + "where did i leave off", + "what was i doing", + "recap my session", + "remind me what i was doing", +] diff --git a/resources/commands/leftoff/recap.lua b/resources/commands/leftoff/recap.lua new file mode 100644 index 0000000..acac5d4 --- /dev/null +++ b/resources/commands/leftoff/recap.lua @@ -0,0 +1,62 @@ +-- Read out a short recap of the user's recent activity. Pulls from four +-- independent sources, picks the freshest, and stitches them into a one-shot +-- TTS reply. Works fully offline — no LLM call. +-- +-- Sources: +-- 1. Last assistant reply (jarvis.llm_last_reply) +-- 2. 3 most recent memory facts (sorted by last_used_at desc) +-- 3. Active scheduled tasks (jarvis.scheduler.list) +-- 4. Active profile (just to colour the tone) + +local lang = jarvis.context.language +local parts = {} + +-- 1) Recent memory facts (last_used_at desc, top 3) +local facts = jarvis.memory.all() or {} +if #facts > 0 then + table.sort(facts, function(a, b) + return (a.last_used_at or 0) > (b.last_used_at or 0) + end) + local items = {} + for i = 1, math.min(3, #facts) do + local v = (facts[i].value or ""):sub(1, 50) + table.insert(items, facts[i].key .. " — " .. v) + end + if lang == "ru" then + table.insert(parts, "Из памяти: " .. table.concat(items, "; ") .. ".") + else + table.insert(parts, "From memory: " .. table.concat(items, "; ") .. ".") + end +end + +-- 2) Upcoming scheduled tasks (just count + the next one) +local tasks = jarvis.scheduler.list() or {} +if #tasks > 0 then + -- Tasks have no "next_fire" exposed at Lua-level, so just count + name first. + local first_name = tasks[1].name or "task" + if lang == "ru" then + table.insert(parts, "В расписании " .. tostring(#tasks) .. ", ближайшее: " .. first_name .. ".") + else + table.insert(parts, tostring(#tasks) .. " scheduled, next: " .. first_name .. ".") + end +end + +-- 3) Last assistant reply (if any) +local last = jarvis.llm_last_reply() +if last and last ~= "" then + -- Trim to ~100 chars to keep the recap snappy + local snippet = last:gsub("%s+", " "):sub(1, 120) + if lang == "ru" then + table.insert(parts, "Я говорил: " .. snippet) + else + table.insert(parts, "I said: " .. snippet) + end +end + +if #parts == 0 then + return jarvis.cmd.not_found(lang == "ru" + and "Ничего недавно не происходило, сэр." + or "Nothing recent to recap, sir.") +end + +return jarvis.cmd.ok(table.concat(parts, " ")) diff --git a/resources/commands/llm_context/command.toml b/resources/commands/llm_context/command.toml new file mode 100644 index 0000000..bdc316a --- /dev/null +++ b/resources/commands/llm_context/command.toml @@ -0,0 +1,37 @@ +# P0.3: Conversation context controls — reset / repeat last reply. + +[[commands]] +id = "llm.reset" +type = "lua" +script = "reset.lua" +sandbox = "standard" +timeout = 2000 + +[commands.phrases] +ru = [ + "сбрось контекст", + "сбрось разговор", + "забудь о чём мы говорили", + "забудь разговор", + "начни разговор заново", + "очисти контекст", +] +en = ["reset context", "forget conversation", "start fresh"] + + +[[commands]] +id = "llm.repeat" +type = "lua" +script = "repeat.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "повтори последнее", + "повтори что сказал", + "повтори ответ", + "что ты сказал", + "повтори", +] +en = ["repeat", "say it again", "repeat the last answer"] diff --git a/resources/commands/llm_context/repeat.lua b/resources/commands/llm_context/repeat.lua new file mode 100644 index 0000000..6b86adb --- /dev/null +++ b/resources/commands/llm_context/repeat.lua @@ -0,0 +1,5 @@ +local last = jarvis.llm_last_reply() +if not last or last == "" then + return jarvis.cmd.not_found("Мне нечего повторить.") +end +return jarvis.cmd.ok(last) diff --git a/resources/commands/llm_context/reset.lua b/resources/commands/llm_context/reset.lua new file mode 100644 index 0000000..6ef82b2 --- /dev/null +++ b/resources/commands/llm_context/reset.lua @@ -0,0 +1,2 @@ +jarvis.llm_reset() +return jarvis.cmd.ok("Контекст сброшен.") diff --git a/resources/commands/llm_switch/command.toml b/resources/commands/llm_switch/command.toml new file mode 100644 index 0000000..09cf252 --- /dev/null +++ b/resources/commands/llm_switch/command.toml @@ -0,0 +1,59 @@ +# Switch between local (Ollama) and cloud (Groq) LLM at runtime. + +[[commands]] +id = "llm.switch.local" +type = "lua" +script = "switch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "переключись на локальный", + "переключись на локальный мозг", + "включи локальный режим", + "перейди на локальный", + "используй локальный", + "перейди на оллама", + "используй оллама", +] +en = ["switch to local", "use local llm", "use ollama"] + + +[[commands]] +id = "llm.switch.cloud" +type = "lua" +script = "switch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "переключись на облако", + "переключись на облачный", + "переключись на грок", + "переключись на гроку", + "используй облако", + "используй грок", + "перейди на облако", +] +en = ["switch to cloud", "use cloud llm", "use groq"] + + +[[commands]] +id = "llm.status" +type = "lua" +script = "status.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "какой у тебя мозг", + "какой ллм", + "какой у тебя llm", + "облако или локально", + "ты сейчас где работаешь", + "что за модель", +] +en = ["which llm", "are you local or cloud", "what model"] diff --git a/resources/commands/llm_switch/status.lua b/resources/commands/llm_switch/status.lua new file mode 100644 index 0000000..c1d0154 --- /dev/null +++ b/resources/commands/llm_switch/status.lua @@ -0,0 +1,8 @@ +local backend = jarvis.llm_status() +if backend == "groq" then + return jarvis.cmd.ok("Сейчас работаю на облаке.") +elseif backend == "ollama" then + return jarvis.cmd.ok("Сейчас работаю локально.") +else + return jarvis.cmd.not_found("LLM не настроен.") +end diff --git a/resources/commands/llm_switch/switch.lua b/resources/commands/llm_switch/switch.lua new file mode 100644 index 0000000..14e0bd9 --- /dev/null +++ b/resources/commands/llm_switch/switch.lua @@ -0,0 +1,22 @@ +-- Map command id → backend name (see command.toml). +local cmd_id = jarvis.context.command_id or "" +local target +if cmd_id:match("local") then + target = "ollama" +elseif cmd_id:match("cloud") then + target = "groq" +else + return jarvis.cmd.error("Не понял на что переключать.") +end + +local result = jarvis.llm_switch(target) +if not result then + local human = (target == "ollama") and "локальный" or "облачный" + return jarvis.cmd.error("Не получилось переключиться на " .. human .. ".") +end + +if result == "groq" then + return jarvis.cmd.ok("Переключился на облачный мозг.") +else + return jarvis.cmd.ok("Переключился на локальный мозг.") +end diff --git a/resources/commands/macros/cancel.lua b/resources/commands/macros/cancel.lua new file mode 100644 index 0000000..e10e867 --- /dev/null +++ b/resources/commands/macros/cancel.lua @@ -0,0 +1,4 @@ +if jarvis.macros.cancel() then + return jarvis.cmd.ok("Запись макроса отменена.") +end +return jarvis.cmd.not_found("Записи не было.") diff --git a/resources/commands/macros/command.toml b/resources/commands/macros/command.toml new file mode 100644 index 0000000..aaa2596 --- /dev/null +++ b/resources/commands/macros/command.toml @@ -0,0 +1,101 @@ +# IMBA-7: Voice macros — record and replay sequences of voice commands. + +[[commands]] +id = "macros.start_recording" +type = "lua" +script = "start.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "запиши макрос", + "начни запись макроса", + "записывай макрос", + "новый макрос", +] +en = ["record macro", "start macro recording"] + + +[[commands]] +id = "macros.save" +type = "lua" +script = "save.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сохрани макрос", + "стоп макрос", + "стоп запись макроса", + "закончи запись", + "макрос готов", +] +en = ["save macro", "stop macro", "finish macro"] + + +[[commands]] +id = "macros.cancel" +type = "lua" +script = "cancel.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "отмени макрос", + "отмени запись макроса", + "отмени запись", + "забудь макрос", +] +en = ["cancel macro", "discard macro"] + + +[[commands]] +id = "macros.replay" +type = "lua" +script = "replay.lua" +sandbox = "standard" +timeout = 60000 + +[commands.phrases] +ru = [ + "запусти макрос", + "выполни макрос", + "воспроизведи макрос", + "сыграй макрос", + "проиграй макрос", +] +en = ["play macro", "replay macro", "run macro"] + + +[[commands]] +id = "macros.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "какие у меня макросы", + "список макросов", + "покажи макросы", +] +en = ["list macros", "show macros"] + + +[[commands]] +id = "macros.delete" +type = "lua" +script = "delete.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "удали макрос", + "забудь о макросе", +] +en = ["delete macro", "remove macro"] diff --git a/resources/commands/macros/delete.lua b/resources/commands/macros/delete.lua new file mode 100644 index 0000000..4215266 --- /dev/null +++ b/resources/commands/macros/delete.lua @@ -0,0 +1,18 @@ +local phrase = (jarvis.context.phrase or ""):lower() +local name = jarvis.text.strip_trigger(phrase, { + "удали макрос", + "забудь о макросе", + "delete macro", + "remove macro", + "видали макрос", +}) +name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if name == "" then + return jarvis.cmd.error("Какой макрос удалить?") +end + +if jarvis.macros.delete(name) then + return jarvis.cmd.ok("Удалил макрос " .. name .. ".") +end +return jarvis.cmd.not_found("Макрос " .. name .. " не найден.") diff --git a/resources/commands/macros/list.lua b/resources/commands/macros/list.lua new file mode 100644 index 0000000..ef6bf64 --- /dev/null +++ b/resources/commands/macros/list.lua @@ -0,0 +1,12 @@ +local list = jarvis.macros.list() +if #list == 0 then + return jarvis.cmd.ok("Макросов нет.") +end + +local sample = math.min(#list, 5) +local line = string.format("Макросов: %d. ", #list) +for i = 1, sample do + line = line .. list[i].name .. " (" .. list[i].steps_count .. " шагов)" + if i < sample then line = line .. ", " end +end +return jarvis.cmd.ok(line .. ".") diff --git a/resources/commands/macros/replay.lua b/resources/commands/macros/replay.lua new file mode 100644 index 0000000..3dce4a7 --- /dev/null +++ b/resources/commands/macros/replay.lua @@ -0,0 +1,24 @@ +-- "Запусти макрос работа" +local phrase = (jarvis.context.phrase or ""):lower() +local name = jarvis.text.strip_trigger(phrase, { + "выполни макрос", + "воспроизведи макрос", + "запусти макрос", + "сыграй макрос", + "проиграй макрос", + "play macro", + "replay macro", + "run macro", +}) +name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if name == "" then + return jarvis.cmd.error("Какой макрос запустить?") +end + +local ok, result = pcall(function() return jarvis.macros.replay(name) end) +if not ok then + return jarvis.cmd.not_found(tostring(result)) +end + +return jarvis.cmd.ok(string.format("Запускаю %s, шагов: %d.", name, result)) diff --git a/resources/commands/macros/save.lua b/resources/commands/macros/save.lua new file mode 100644 index 0000000..750a506 --- /dev/null +++ b/resources/commands/macros/save.lua @@ -0,0 +1,8 @@ +local ok, result = pcall(function() return jarvis.macros.save() end) +if not ok then + -- result here is the error msg from save_recording + return jarvis.cmd.not_found(tostring(result)) +end + +local name = jarvis.macros.recording_name() or "макрос" +return jarvis.cmd.ok(string.format("Сохранил %d шагов.", result)) diff --git a/resources/commands/macros/start.lua b/resources/commands/macros/start.lua new file mode 100644 index 0000000..164acaf --- /dev/null +++ b/resources/commands/macros/start.lua @@ -0,0 +1,23 @@ +-- "Запиши макрос работа" +local phrase = (jarvis.context.phrase or ""):lower() +local name = jarvis.text.strip_trigger(phrase, { + "начни запись макроса", + "записывай макрос", + "запиши макрос", + "новый макрос", + "record macro", + "start macro recording", +}) +name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if name == "" then + return jarvis.cmd.error("Как назвать макрос?") +end + +local ok, err = pcall(function() jarvis.macros.start(name) end) +if not ok then + jarvis.log("warn", "macros.start: " .. tostring(err)) + return jarvis.cmd.error("Не получилось начать запись.") +end + +return jarvis.cmd.ok(string.format("Записываю макрос %s. Говорите команды. Когда закончите — скажите сохрани макрос.", name)) diff --git a/resources/commands/magic_8ball/ask.lua b/resources/commands/magic_8ball/ask.lua new file mode 100644 index 0000000..04e670c --- /dev/null +++ b/resources/commands/magic_8ball/ask.lua @@ -0,0 +1,24 @@ +local answers = { + -- positive + "Без сомнения, сэр.", + "Однозначно да.", + "Можете на это рассчитывать.", + "Скорее всего да.", + "Перспективы хорошие.", + "Знаки указывают на да.", + -- neutral + "Пока неясно, попробуйте позже.", + "Сосредоточьтесь и спросите снова.", + "Не могу предсказать сейчас.", + "Лучше не говорить.", + -- negative + "Не рассчитывайте на это.", + "Мой ответ — нет.", + "Источники говорят нет.", + "Перспективы не очень.", + "Очень сомнительно.", +} + +math.randomseed(os.time() + (jarvis.context.time.minute or 0) * 31 + (jarvis.context.time.second or 0)) +local idx = math.random(1, #answers) +return jarvis.cmd.ok(answers[idx]) diff --git a/resources/commands/magic_8ball/command.toml b/resources/commands/magic_8ball/command.toml new file mode 100644 index 0000000..50ef0ea --- /dev/null +++ b/resources/commands/magic_8ball/command.toml @@ -0,0 +1,19 @@ +# Classic magic 8-ball. + +[[commands]] +id = "magic_8ball" +type = "lua" +script = "ask.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "ответь да или нет", + "магический шар", + "восьмой шар", + "скажи да или нет", + "что скажешь", + "предсказание", +] +en = ["yes or no", "magic 8-ball", "tell me yes or no"] diff --git a/resources/commands/mailto/command.toml b/resources/commands/mailto/command.toml new file mode 100644 index 0000000..ec4d141 --- /dev/null +++ b/resources/commands/mailto/command.toml @@ -0,0 +1,17 @@ +# Mailto: open default mail client to compose a new message. + +[[commands]] +id = "mailto.compose" +type = "lua" +script = "compose.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "напиши письмо", + "новое письмо", + "открой почту", + "составить письмо", +] +en = ["compose email", "new email", "open mail"] diff --git a/resources/commands/mailto/compose.lua b/resources/commands/mailto/compose.lua new file mode 100644 index 0000000..2143ee8 --- /dev/null +++ b/resources/commands/mailto/compose.lua @@ -0,0 +1,32 @@ +-- "Напиши письмо имя_контакта" → opens mailto: URI in default handler. +-- For now just opens a blank compose; could be extended to pull recipient +-- from memory ("маме", "коллеге") via lookups. +local phrase = (jarvis.context.phrase or ""):lower() +local target = jarvis.text.strip_trigger(phrase, { + "напиши письмо", + "новое письмо", + "открой почту", + "составить письмо", + "compose email", + "new email", + "open mail", +}) +target = target:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +local recipient = "" +if target ~= "" then + -- Try memory lookup — "напиши письмо маме" → memory.recall("email_маме") + local key = "email_" .. target + local addr = jarvis.memory.recall(key) + if addr and addr ~= "" then + recipient = addr + end +end + +local uri = recipient ~= "" and ("mailto:" .. recipient) or "mailto:" +jarvis.system.open(uri) + +if recipient ~= "" then + return jarvis.cmd.ok("Открываю письмо для " .. target .. ".") +end +return jarvis.cmd.ok("Открываю почтовый клиент.") diff --git a/resources/commands/math/command.toml b/resources/commands/math/command.toml new file mode 100644 index 0000000..0a73b09 --- /dev/null +++ b/resources/commands/math/command.toml @@ -0,0 +1,22 @@ +[[commands]] +id = "math_eval" +type = "lua" +script = "math.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "посчитай", + "сколько будет", + "вычисли", + "реши", + "сколько это", +] +en = [ + "calculate", + "how much is", + "compute", + "solve", + "what is", +] diff --git a/resources/commands/math/math.lua b/resources/commands/math/math.lua new file mode 100644 index 0000000..c8e8ccc --- /dev/null +++ b/resources/commands/math/math.lua @@ -0,0 +1,272 @@ +-- Quick math — offline-first arithmetic evaluator. +-- +-- Strategy: +-- 1. Strip the trigger phrase from the user's utterance. +-- 2. Normalise Russian/English operator words (плюс/minus/в степени/...) into +-- symbols so the remainder looks like a regular numeric expression. +-- 3. Try a small shunting-yard parser on the result. If it succeeds and the +-- output is finite, speak it INSTANTLY — no LLM round-trip. +-- 4. Only fall back to the LLM if the local parse failed or returned NaN. +-- That covers word problems, multi-step equations, unit conversions. +-- +-- This makes 95% of "сколько будет X плюс Y" complete in under 50ms regardless +-- of network or GROQ_TOKEN. + +local lang = jarvis.context.language +local raw = (jarvis.context.phrase or ""):lower() + +local triggers = { + "сколько будет", "сколько это", "посчитай", "вычисли", "реши", + "how much is", "calculate", "compute", "solve", "what is", +} +local query = jarvis.text.strip_trigger(raw, triggers) +if not query or query == "" then query = raw end +query = query:gsub("^%s+", ""):gsub("%s+$", "") + +if query == "" then + return jarvis.cmd.not_found(lang == "ru" and "Что посчитать?" or "What to compute?") +end + +-- ── 1. Russian/English operator → symbol normalisation ──────────────────── + +local function normalise(s) + local mapping = { + { "плюс", "+" }, + { "прибавить к?", "+" }, + { "минус", "-" }, + { "отнять", "-" }, + { "вычесть", "-" }, + { "умножить на", "*" }, + { "умножь на", "*" }, + { "помножить на", "*" }, + { "разделить на", "/" }, + { "поделить на", "/" }, + { "делить на", "/" }, + { "в степени", "^" }, + { "возвести в степень", "^" }, + { "остаток от деления на", "%%" }, + { "по модулю", "%%" }, + { "квадрат", "^2" }, + { "куб", "^3" }, + { "plus", "+" }, + { "minus", "-" }, + { "times", "*" }, + { "multiplied by", "*" }, + { "divided by", "/" }, + { "to the power of", "^" }, + { "squared", "^2" }, + { "cubed", "^3" }, + { "modulo", "%%" }, + { "mod", "%%" }, + } + for _, pair in ipairs(mapping) do + s = s:gsub(pair[1], pair[2]) + end + -- comma as decimal separator (русская запись) + s = s:gsub("(%d),(%d)", "%1.%2") + return s +end + +-- Pattern: "корень из X" / "квадратный корень из X" → math.sqrt +local function handle_sqrt(s) + local n = s:match("^%s*к?орен?ь?%s*и?з?%s+(%-?[%d%.]+)%s*$") + if not n then n = s:match("^%s*sqrt%s*%(?%s*(%-?[%d%.]+)%s*%)?%s*$") end + if n then + local v = tonumber(n) + if v and v >= 0 then return math.sqrt(v) end + end + return nil +end + +-- Pattern: "X процентов от Y" / "X percent of Y" → X * Y / 100 +local function handle_percent(s) + local x, y = s:match("^%s*(%-?[%d%.]+)%s*процент[%a]*%s*от%s+(%-?[%d%.]+)%s*$") + if not x then + x, y = s:match("^%s*(%-?[%d%.]+)%s*percent%s*of%s+(%-?[%d%.]+)%s*$") + end + if x and y then + local fx, fy = tonumber(x), tonumber(y) + if fx and fy then return fx * fy / 100 end + end + return nil +end + +-- ── 2. Tiny shunting-yard for " ..." with parens +local function tokenize(s) + local tokens = {} + local i = 1 + while i <= #s do + local c = s:sub(i, i) + if c:match("[%s]") then + i = i + 1 + elseif c:match("[%d%.]") then + local j = i + while j <= #s and s:sub(j, j):match("[%d%.]") do j = j + 1 end + table.insert(tokens, { kind = "num", val = tonumber(s:sub(i, j - 1)) }) + i = j + elseif c == "+" or c == "-" or c == "*" or c == "/" or c == "^" or c == "%" then + -- handle unary minus / plus at expression start or after operator/paren + if (c == "-" or c == "+") and (#tokens == 0 or tokens[#tokens].kind == "op" or tokens[#tokens].kind == "lparen") then + local j = i + 1 + while j <= #s and s:sub(j, j):match("[%s]") do j = j + 1 end + if j <= #s and s:sub(j, j):match("[%d%.]") then + local k = j + while k <= #s and s:sub(k, k):match("[%d%.]") do k = k + 1 end + local v = tonumber(s:sub(j, k - 1)) + if v then + table.insert(tokens, { kind = "num", val = (c == "-" and -v or v) }) + i = k + else + return nil + end + else + return nil + end + else + table.insert(tokens, { kind = "op", val = c }) + i = i + 1 + end + elseif c == "(" then + table.insert(tokens, { kind = "lparen" }); i = i + 1 + elseif c == ")" then + table.insert(tokens, { kind = "rparen" }); i = i + 1 + else + return nil -- unknown char + end + end + return tokens +end + +local function precedence(op) + if op == "+" or op == "-" then return 1 end + if op == "*" or op == "/" or op == "%" then return 2 end + if op == "^" then return 3 end + return 0 +end + +local function eval_op(a, b, op) + if op == "+" then return a + b end + if op == "-" then return a - b end + if op == "*" then return a * b end + if op == "/" then if b == 0 then return nil end; return a / b end + if op == "%" then if b == 0 then return nil end; return a % b end + if op == "^" then return a ^ b end + return nil +end + +local function shunting_yard_eval(tokens) + if not tokens or #tokens == 0 then return nil end + local values, ops = {}, {} + + local function apply() + local op = table.remove(ops) + local b = table.remove(values) + local a = table.remove(values) + if a == nil or b == nil then return false end + local r = eval_op(a, b, op) + if r == nil then return false end + table.insert(values, r) + return true + end + + for _, t in ipairs(tokens) do + if t.kind == "num" then + table.insert(values, t.val) + elseif t.kind == "op" then + while #ops > 0 and ops[#ops] ~= "(" and precedence(ops[#ops]) >= precedence(t.val) do + if not apply() then return nil end + end + table.insert(ops, t.val) + elseif t.kind == "lparen" then + table.insert(ops, "(") + elseif t.kind == "rparen" then + while #ops > 0 and ops[#ops] ~= "(" do + if not apply() then return nil end + end + if ops[#ops] ~= "(" then return nil end + table.remove(ops) + end + end + while #ops > 0 do + if ops[#ops] == "(" then return nil end + if not apply() then return nil end + end + if #values ~= 1 then return nil end + return values[1] +end + +-- ── 3. Format result for speech (no scientific notation, trim trailing zeros) +local function format_result(n) + if n ~= n then return nil end -- NaN + if n == math.huge or n == -math.huge then return nil end + if math.abs(n - math.floor(n)) < 1e-9 then + return tostring(math.floor(n + (n < 0 and -0.5 or 0.5))) + end + local s = string.format("%.4f", n) + s = s:gsub("0+$", ""):gsub("%.$", "") + return s +end + +-- ── 4. Try offline first ────────────────────────────────────────────────── + +local normalised = normalise(query) +jarvis.log("info", "math normalised: " .. normalised) + +local result_num = + handle_sqrt(normalised) + or handle_percent(normalised) + or shunting_yard_eval(tokenize(normalised)) + +if result_num ~= nil then + local pretty = format_result(result_num) + if pretty then + local prefix = (lang == "ru" and "Получилось " or "Result: ") + return jarvis.cmd.ok(prefix .. pretty) + end +end + +-- ── 5. LLM fallback for word-problems / equations / conversions ─────────── + +local token = jarvis.system.env("GROQ_TOKEN") +if not token or token == "" then + return jarvis.cmd.error(lang == "ru" + and "Не понял выражение, и GROQ_TOKEN не задан." + or "Couldn't parse, and GROQ_TOKEN unset.") +end + +local base = jarvis.system.env("GROQ_BASE_URL") +if not base or base == "" then base = "https://api.groq.com/openai/v1" end +local model = jarvis.system.env("GROQ_MODEL") +if not model or model == "" then model = "llama-3.3-70b-versatile" end + +local sys = "Ты калькулятор. Решай арифметику, простые уравнения, конверсии единиц, проценты. " + .. "Отвечай ТОЛЬКО результатом числом или короткой строкой. " + .. "Если запрос — не математика, ответь одним словом «нет»." + +local payload = { + model = model, + messages = { + { role = "system", content = sys }, + { role = "user", content = query }, + }, + max_tokens = 64, + temperature = 0.0, +} + +jarvis.log("info", "math fallback to LLM: " .. query) +local res = jarvis.http.post_json(base .. "/chat/completions", payload, { Authorization = "Bearer " .. token }) +if not res.ok then + return jarvis.cmd.error("Ошибка API: " .. tostring(res.status)) +end +local content = (res.body or ""):match('"content"%s*:%s*"(.-[^\\])"') +if not content then + return jarvis.cmd.error(lang == "ru" and "Не смог распарсить ответ" or "Couldn't parse response") +end +content = content:gsub('\\n', '\n'):gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\t', '\t') +content = content:gsub("^%s+", ""):gsub("%s+$", "") +if content:lower() == "нет" or content == "" then + return jarvis.cmd.not_found(lang == "ru" and "Это не математика" or "Not math") +end + +local prefix = (lang == "ru" and "Получилось " or "Result: ") +return jarvis.cmd.ok(prefix .. content) diff --git a/resources/commands/media/_media_helper.ps1 b/resources/commands/media/_media_helper.ps1 new file mode 100644 index 0000000..130aedc --- /dev/null +++ b/resources/commands/media/_media_helper.ps1 @@ -0,0 +1,20 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet("play_pause","next","prev","stop")] + [string]$Action +) + +Add-Type -Name MediaKey -Namespace Win32 -MemberDefinition @' +[DllImport("user32.dll")] +public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, System.UIntPtr dwExtraInfo); +'@ + +$vk = switch ($Action) { + "play_pause" { 0xB3 } + "next" { 0xB0 } + "prev" { 0xB1 } + "stop" { 0xB2 } +} + +[Win32.MediaKey]::keybd_event($vk, 0, 0, [UIntPtr]::Zero) +[Win32.MediaKey]::keybd_event($vk, 0, 2, [UIntPtr]::Zero) diff --git a/resources/commands/media/command.toml b/resources/commands/media/command.toml new file mode 100644 index 0000000..35535c5 --- /dev/null +++ b/resources/commands/media/command.toml @@ -0,0 +1,85 @@ +[[commands]] +id = "media_play_pause" +type = "lua" +script = "play_pause.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "пауза", + "поставь на паузу", + "продолжи", + "включи музыку", + "запусти музыку", + "плей", +] +en = [ + "pause", + "play", + "resume", + "play music", +] + + +[[commands]] +id = "media_next" +type = "lua" +script = "next_track.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "следующий трек", + "следующая песня", + "переключи трек", + "следующая", + "дальше", +] +en = [ + "next track", + "next song", + "skip", + "next", +] + + +[[commands]] +id = "media_prev" +type = "lua" +script = "prev_track.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "предыдущий трек", + "предыдущая песня", + "верни песню", + "назад", +] +en = [ + "previous track", + "previous song", + "back", +] + + +[[commands]] +id = "media_stop" +type = "lua" +script = "stop_media.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "стоп музыка", + "останови музыку", + "выключи музыку", +] +en = [ + "stop music", + "stop playback", +] diff --git a/resources/commands/media/next_track.lua b/resources/commands/media/next_track.lua new file mode 100644 index 0000000..7a8eeb7 --- /dev/null +++ b/resources/commands/media/next_track.lua @@ -0,0 +1,13 @@ +local helper = jarvis.context.command_path .. "\\_media_helper.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action next', + helper +) +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "media next failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/media/play_pause.lua b/resources/commands/media/play_pause.lua new file mode 100644 index 0000000..d54358a --- /dev/null +++ b/resources/commands/media/play_pause.lua @@ -0,0 +1,13 @@ +local helper = jarvis.context.command_path .. "\\_media_helper.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action play_pause', + helper +) +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "play/pause failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/media/prev_track.lua b/resources/commands/media/prev_track.lua new file mode 100644 index 0000000..16da444 --- /dev/null +++ b/resources/commands/media/prev_track.lua @@ -0,0 +1,13 @@ +local helper = jarvis.context.command_path .. "\\_media_helper.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action prev', + helper +) +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "media prev failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/media/stop_media.lua b/resources/commands/media/stop_media.lua new file mode 100644 index 0000000..d458b24 --- /dev/null +++ b/resources/commands/media/stop_media.lua @@ -0,0 +1,13 @@ +local helper = jarvis.context.command_path .. "\\_media_helper.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action stop', + helper +) +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "media stop failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/media_keys/_media.ps1 b/resources/commands/media_keys/_media.ps1 new file mode 100644 index 0000000..fa40948 --- /dev/null +++ b/resources/commands/media_keys/_media.ps1 @@ -0,0 +1,18 @@ +# Helper: send a Windows media key via user32.keybd_event. +# Called by key.lua with one argument = decimal VK code. +param([int]$Vk) + +if (-not $Vk) { exit 1 } + +Add-Type -TypeDefinition @" +using System.Runtime.InteropServices; +public class MediaKey { + [DllImport("user32.dll")] + public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, int dwExtraInfo); +} +"@ + +# down + up +[MediaKey]::keybd_event([byte]$Vk, 0, 0, 0) +[MediaKey]::keybd_event([byte]$Vk, 0, 2, 0) +exit 0 diff --git a/resources/commands/media_keys/command.toml b/resources/commands/media_keys/command.toml new file mode 100644 index 0000000..e0e4ac1 --- /dev/null +++ b/resources/commands/media_keys/command.toml @@ -0,0 +1,76 @@ +# Media playback control via Windows virtual media keys. +# Works with anything that listens to global media keys: Spotify, Yandex Music, +# YouTube in browser (when tab is focused), Foobar2000, Winamp, etc. +# No OAuth, no API keys — just sends VK_MEDIA_* via PowerShell SendKeys. + +[[commands]] +id = "media.play_pause" +type = "lua" +script = "key.lua" +sandbox = "full" +timeout = 2000 + +[commands.phrases] +ru = [ + "пауза", + "продолжи", + "плей", + "включи музыку", + "выключи музыку", + "приостанови музыку", + "снова музыку", +] +en = ["pause", "play", "resume music", "stop music"] + + +[[commands]] +id = "media.next" +type = "lua" +script = "key.lua" +sandbox = "full" +timeout = 2000 + +[commands.phrases] +ru = [ + "следующий трек", + "следующую песню", + "перемотай вперёд", + "вперёд трек", + "переключи на следующую", + "next", +] +en = ["next track", "next song", "skip"] + + +[[commands]] +id = "media.prev" +type = "lua" +script = "key.lua" +sandbox = "full" +timeout = 2000 + +[commands.phrases] +ru = [ + "предыдущий трек", + "предыдущую песню", + "перемотай назад", + "назад трек", + "переключи на предыдущую", + "previous", +] +en = ["previous track", "previous song", "back"] + + +[[commands]] +id = "media.stop" +type = "lua" +script = "key.lua" +sandbox = "full" +timeout = 2000 + +[commands.phrases] +ru = [ + "стоп музыка", + "останови плеер", +] +en = ["stop", "stop player"] diff --git a/resources/commands/media_keys/key.lua b/resources/commands/media_keys/key.lua new file mode 100644 index 0000000..dc695be --- /dev/null +++ b/resources/commands/media_keys/key.lua @@ -0,0 +1,26 @@ +-- Map command id → VK_MEDIA_* virtual key code. +-- See https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes +local cmd_id = jarvis.context.command_id or "" + +local VK = { + ["media.play_pause"] = 179, -- VK_MEDIA_PLAY_PAUSE (0xB3) + ["media.next"] = 176, -- VK_MEDIA_NEXT_TRACK (0xB0) + ["media.prev"] = 177, -- VK_MEDIA_PREV_TRACK (0xB1) + ["media.stop"] = 178, -- VK_MEDIA_STOP (0xB2) +} + +local code = VK[cmd_id] +if not code then + return jarvis.cmd.error("Неизвестная медиа-команда.") +end + +local helper = jarvis.context.command_path .. "\\_media.ps1" +local cmd_str = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" %d', + helper, code +) + +jarvis.system.exec(cmd_str) + +-- silent — feedback would be annoying for media-key spam +return jarvis.cmd.silent_ok() diff --git a/resources/commands/memory_admin/command.toml b/resources/commands/memory_admin/command.toml new file mode 100644 index 0000000..ebec6b5 --- /dev/null +++ b/resources/commands/memory_admin/command.toml @@ -0,0 +1,66 @@ +# Memory admin — voice-driven housekeeping for the long-term memory. +# - "сколько фактов помнишь" → speak count +# - "выгрузи список фактов" → notify with first ~10 keys +# - "забудь все" → wipe everything (with safety prefix "точно забудь все") + +[[commands]] +id = "memory_admin.count" +type = "lua" +script = "count.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "сколько фактов помнишь", + "сколько ты помнишь", + "сколько у тебя в памяти", + "размер памяти", +] +en = [ + "how many facts do you remember", + "how big is your memory", + "memory size", +] + + +[[commands]] +id = "memory_admin.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "выгрузи список фактов", + "покажи что помнишь", + "перечисли что помнишь", + "что у тебя в памяти", +] +en = [ + "list what you remember", + "show me your memory", + "what's in memory", +] + + +[[commands]] +id = "memory_admin.wipe" +type = "lua" +script = "wipe.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "точно забудь все", + "точно забудь всё", + "сотри всю память", + "wipe memory", +] +en = [ + "wipe all memory", + "really forget everything", + "clear all memory", +] diff --git a/resources/commands/memory_admin/count.lua b/resources/commands/memory_admin/count.lua new file mode 100644 index 0000000..2dc5bf2 --- /dev/null +++ b/resources/commands/memory_admin/count.lua @@ -0,0 +1,20 @@ +local lang = jarvis.context.language +local facts = jarvis.memory.all() or {} +local n = #facts + +local function ru_plural_facts(n) + local m100 = n % 100 + if m100 >= 11 and m100 <= 14 then return "фактов" end + local m10 = n % 10 + if m10 == 1 then return "факт" end + if m10 >= 2 and m10 <= 4 then return "факта" end + return "фактов" +end + +local msg +if lang == "ru" then + msg = "В памяти " .. tostring(n) .. " " .. ru_plural_facts(n) .. ", сэр." +else + msg = "I remember " .. tostring(n) .. (n == 1 and " fact, sir." or " facts, sir.") +end +return jarvis.cmd.ok(msg) diff --git a/resources/commands/memory_admin/list.lua b/resources/commands/memory_admin/list.lua new file mode 100644 index 0000000..569b05e --- /dev/null +++ b/resources/commands/memory_admin/list.lua @@ -0,0 +1,37 @@ +-- Read out the first ~5 keys + value previews; the rest is just a count. +-- TTS doesn't handle long enumerations well, so we keep it short and notify +-- the full dump as a Windows toast for visual scan. + +local lang = jarvis.context.language +local facts = jarvis.memory.all() or {} + +if #facts == 0 then + return jarvis.cmd.not_found(lang == "ru" + and "Память пуста, сэр." + or "Memory is empty, sir.") +end + +-- Sort by most-recently-used so the user hears what's actually fresh. +table.sort(facts, function(a, b) return (a.last_used_at or 0) > (b.last_used_at or 0) end) + +local speak_lines = {} +local notify_lines = {} +for i, f in ipairs(facts) do + local v = (f.value or ""):sub(1, 60) + local line = f.key .. ": " .. v + table.insert(notify_lines, line) + if i <= 5 then + table.insert(speak_lines, f.key .. " — " .. v) + end +end + +jarvis.system.notify( + lang == "ru" and ("Память (" .. tostring(#facts) .. ")") or ("Memory (" .. tostring(#facts) .. ")"), + table.concat(notify_lines, "\n") +) + +local preview = table.concat(speak_lines, ", ") +local extra = #facts > 5 + and string.format(lang == "ru" and ". И ещё %d." or ". And %d more.", #facts - 5) + or "." +return jarvis.cmd.ok(preview .. extra) diff --git a/resources/commands/memory_admin/wipe.lua b/resources/commands/memory_admin/wipe.lua new file mode 100644 index 0000000..6c3e368 --- /dev/null +++ b/resources/commands/memory_admin/wipe.lua @@ -0,0 +1,13 @@ +-- Nuclear "forget everything" — guarded by the "точно" prefix in the toml +-- phrases so a stray "забудь" doesn't trigger it. +local lang = jarvis.context.language +local n = jarvis.memory.clear_all() + +if n == 0 then + return jarvis.cmd.ok(lang == "ru" and "Память уже пуста, сэр." + or "Memory is already empty, sir.") +end + +return jarvis.cmd.ok(string.format(lang == "ru" + and "Стёр %d записей. Память чиста." + or "Wiped %d records. Memory is clean.", n)) diff --git a/resources/commands/memory_pack/command.toml b/resources/commands/memory_pack/command.toml new file mode 100644 index 0000000..b600991 --- /dev/null +++ b/resources/commands/memory_pack/command.toml @@ -0,0 +1,55 @@ +# IMBA-2: Long-term memory — remember/recall/forget arbitrary facts. + +[[commands]] +id = "memory.remember" +type = "lua" +script = "remember.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "запомни обо мне", "запомни что", + "помни что", "запомни это", + "запомни про меня", +] +en = ["remember that", "remember about me", "memorize"] + + +[[commands]] +id = "memory.recall" +type = "lua" +script = "recall.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "что ты помнишь о", "что ты знаешь обо мне", + "что помнишь про", "вспомни", +] +en = ["what do you remember about", "recall"] + + +[[commands]] +id = "memory.forget" +type = "lua" +script = "forget.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = ["забудь про", "забудь что", "забудь обо мне"] +en = ["forget about", "forget that"] + + +[[commands]] +id = "memory.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = ["что ты помнишь", "что ты знаешь обо мне", "покажи память"] +en = ["what do you remember", "list memory", "show memory"] diff --git a/resources/commands/memory_pack/forget.lua b/resources/commands/memory_pack/forget.lua new file mode 100644 index 0000000..40fa083 --- /dev/null +++ b/resources/commands/memory_pack/forget.lua @@ -0,0 +1,36 @@ +local phrase = (jarvis.context.phrase or ""):lower() +local key = jarvis.text.strip_trigger(phrase, { + "забудь про", + "забудь что", + "забудь обо мне", + "forget about", + "forget that", + "забудь про", +}) + +key = key:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if key == "" then + jarvis.speak("Что именно забыть?") + jarvis.audio.play_error() + return { chain = false } +end + +local removed = jarvis.memory.forget(key) +if removed then + jarvis.speak("Забыл про " .. key .. ".") + jarvis.audio.play_ok() +else + -- try substring search and remove first hit + local hits = jarvis.memory.search(key, 1) + if #hits > 0 then + jarvis.memory.forget(hits[1].key) + jarvis.speak("Забыл про " .. hits[1].key .. ".") + jarvis.audio.play_ok() + else + jarvis.speak("Я и не помнил про " .. key .. ".") + jarvis.audio.play_not_found() + end +end + +return { chain = false } diff --git a/resources/commands/memory_pack/list.lua b/resources/commands/memory_pack/list.lua new file mode 100644 index 0000000..302dc5c --- /dev/null +++ b/resources/commands/memory_pack/list.lua @@ -0,0 +1,15 @@ +local recs = jarvis.memory.all() +if #recs == 0 then + return jarvis.cmd.ok("Я пока ничего о вас не запомнил.") +end + +local count = #recs +local sample = math.min(count, 5) +local line = string.format("Помню %d фактов. Первые: ", count) +for i = 1, sample do + line = line .. recs[i].key + if i < sample then line = line .. ", " end +end +line = line .. "." + +return jarvis.cmd.ok(line) diff --git a/resources/commands/memory_pack/recall.lua b/resources/commands/memory_pack/recall.lua new file mode 100644 index 0000000..bd4e1a2 --- /dev/null +++ b/resources/commands/memory_pack/recall.lua @@ -0,0 +1,52 @@ +local phrase = (jarvis.context.phrase or ""):lower() +local query = jarvis.text.strip_trigger(phrase, { + "что ты помнишь о", + "что ты помнишь про", + "что ты знаешь обо мне", + "что помнишь про", + "вспомни", + "what do you remember about", + "recall", + "що ти пам'ятаєш про", +}) + +query = query:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if query == "" then + -- show top-3 most recent + local recs = jarvis.memory.all() + if #recs == 0 then + jarvis.speak("Ничего пока не помню.") + jarvis.audio.play_ok() + return { chain = false } + end + local line = "Я помню: " + for i = 1, math.min(3, #recs) do + line = line .. recs[i].key .. " — " .. recs[i].value .. ". " + end + jarvis.speak(line) + jarvis.audio.play_ok() + return { chain = false } +end + +-- substring search +local hits = jarvis.memory.search(query, 3) +if #hits == 0 then + jarvis.speak("Ничего не нашёл про " .. query .. ".") + jarvis.audio.play_not_found() + return { chain = false } +end + +local line = "" +if #hits == 1 then + line = hits[1].value +else + for i, h in ipairs(hits) do + line = line .. h.key .. ": " .. h.value + if i < #hits then line = line .. ". " end + end +end + +jarvis.speak(line) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/memory_pack/remember.lua b/resources/commands/memory_pack/remember.lua new file mode 100644 index 0000000..cca722f --- /dev/null +++ b/resources/commands/memory_pack/remember.lua @@ -0,0 +1,55 @@ +local phrase = (jarvis.context.phrase or ""):lower() +local body = jarvis.text.strip_trigger(phrase, { + "запомни обо мне что", + "запомни обо мне", + "запомни что", + "помни что", + "запомни это", + "запомни про меня", + "запомни", + "remember that", + "remember about me", + "memorize", + "запам'ятай що", + "запам'ятай про мене", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + jarvis.speak("Что именно запомнить?") + jarvis.audio.play_error() + return { chain = false } +end + +-- Heuristic: derive a key. Look for "что X = Y" / "что у меня X" / etc. +-- For now: first 3-5 meaningful words become the key. +local key = body +local _, after_eq = body:find("=", 1, true) +if after_eq then + key = body:sub(1, after_eq - 1):gsub("%s+$", "") + body = body:sub(after_eq + 1):gsub("^%s+", "") +end + +-- truncate key to first 6 words +local words = {} +for w in key:gmatch("%S+") do + table.insert(words, w) + if #words >= 6 then break end +end +key = table.concat(words, " ") + +local ok, err = pcall(function() + jarvis.memory.remember(key, body) +end) + +if not ok then + jarvis.log("warn", "memory.remember failed: " .. tostring(err)) + jarvis.speak("Не удалось запомнить.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak("Запомнил.") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/mood_log/command.toml b/resources/commands/mood_log/command.toml new file mode 100644 index 0000000..bdc091f --- /dev/null +++ b/resources/commands/mood_log/command.toml @@ -0,0 +1,37 @@ +# Mood / energy log — daily journal entries via voice. +# Each entry stored via jarvis.memory with key = "mood_" so it survives restart. + +[[commands]] +id = "mood.record" +type = "lua" +script = "record.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "запиши настроение", + "настроение", + "запиши настроение на", + "сегодня мне", + "мой день был", + "мне сейчас", +] +en = ["log mood", "my mood is", "I feel"] + + +[[commands]] +id = "mood.recap" +type = "lua" +script = "recap.lua" +sandbox = "standard" +timeout = 15000 + +[commands.phrases] +ru = [ + "как прошла неделя", + "недельный отчет настроения", + "сводка настроения", + "что я писал про настроение", +] +en = ["mood recap", "weekly mood summary", "how was my week"] diff --git a/resources/commands/mood_log/recap.lua b/resources/commands/mood_log/recap.lua new file mode 100644 index 0000000..c57e18a --- /dev/null +++ b/resources/commands/mood_log/recap.lua @@ -0,0 +1,33 @@ +-- Pull all mood_* entries, ask LLM to summarize trends. +local all = jarvis.memory.all() +local moods = {} +for _, rec in ipairs(all) do + if rec.key:sub(1, 5) == "mood_" then + table.insert(moods, rec.value) + end +end + +if #moods == 0 then + return jarvis.cmd.not_found("Записей о настроении пока нет.") +end + +-- Trim to most-recent-ish 30 entries (memory.all isn't ordered, but mood_ keys sort) +table.sort(moods) +local recent = {} +local start = math.max(1, #moods - 30 + 1) +for i = start, #moods do + table.insert(recent, moods[i]) +end + +local joined = table.concat(recent, "\n") + +local summary = jarvis.llm({ + { role = "system", content = "Ты — психотерапевт-помощник. На основе записей пользователя о настроении дай короткую сводку (3-5 предложений) на русском: общий тон, тренды, выдели хороший/плохой день. Без банальностей, без советов." }, + { role = "user", content = "Записи:\n" .. joined } +}, { max_tokens = 250, temperature = 0.5 }) + +if not summary or summary == "" then + return jarvis.cmd.error("Не получилось обобщить. Возможно, нет связи с LLM.") +end + +return jarvis.cmd.ok(summary) diff --git a/resources/commands/mood_log/record.lua b/resources/commands/mood_log/record.lua new file mode 100644 index 0000000..9a97a7a --- /dev/null +++ b/resources/commands/mood_log/record.lua @@ -0,0 +1,37 @@ +-- "Запиши настроение 7" / "сегодня мне грустно" / "настроение восемь, продуктивно" +local phrase = (jarvis.context.phrase or "") + +local body = jarvis.text.strip_trigger(phrase:lower(), { + "запиши настроение на", + "запиши настроение", + "недельный отчет", + "настроение", + "сегодня мне", + "мой день был", + "мне сейчас", + "log mood", + "my mood is", + "i feel", + "запиши настрій", +}) +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + return jarvis.cmd.error("Что записать про настроение?") +end + +local t = jarvis.context.time +local stamp = string.format("%04d-%02d-%02d %02d:%02d", + t.year, t.month, t.day, t.hour, t.minute) +local key = "mood_" .. (t.timestamp or os.time()) +local value = stamp .. " — " .. body + +local ok, err = pcall(function() + jarvis.memory.remember(key, value) +end) +if not ok then + jarvis.log("warn", "mood.record: " .. tostring(err)) + return jarvis.cmd.error("Не получилось записать.") +end + +return jarvis.cmd.ok("Записал.") diff --git a/resources/commands/mouse/_mouse_helper.ps1 b/resources/commands/mouse/_mouse_helper.ps1 new file mode 100644 index 0000000..25374e7 --- /dev/null +++ b/resources/commands/mouse/_mouse_helper.ps1 @@ -0,0 +1,33 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet("left","right","middle","double","scroll_up","scroll_down")] + [string]$Action +) + +Add-Type -Name MouseM -Namespace Win32 -MemberDefinition @' +[DllImport("user32.dll")] +public static extern void mouse_event(uint flags, int dx, int dy, int data, System.UIntPtr extra); +'@ + +$LEFTDOWN = 0x0002 +$LEFTUP = 0x0004 +$RIGHTDOWN = 0x0008 +$RIGHTUP = 0x0010 +$MIDDOWN = 0x0020 +$MIDUP = 0x0040 +$WHEEL = 0x0800 + +function Click([uint32]$down, [uint32]$up) { + [Win32.MouseM]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 30 + [Win32.MouseM]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero) +} + +switch ($Action) { + "left" { Click $LEFTDOWN $LEFTUP } + "right" { Click $RIGHTDOWN $RIGHTUP } + "middle" { Click $MIDDOWN $MIDUP } + "double" { Click $LEFTDOWN $LEFTUP; Start-Sleep -Milliseconds 50; Click $LEFTDOWN $LEFTUP } + "scroll_up" { [Win32.MouseM]::mouse_event($WHEEL, 0, 0, 360, [UIntPtr]::Zero) } + "scroll_down" { [Win32.MouseM]::mouse_event($WHEEL, 0, 0, -360, [UIntPtr]::Zero) } +} diff --git a/resources/commands/mouse/command.toml b/resources/commands/mouse/command.toml new file mode 100644 index 0000000..b41a50c --- /dev/null +++ b/resources/commands/mouse/command.toml @@ -0,0 +1,58 @@ +[[commands]] +id = "mouse_left_click" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["клик", "кликни", "нажми мышкой"] +en = ["click", "left click"] + + +[[commands]] +id = "mouse_right_click" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["правый клик", "клик правой", "правой кнопкой"] +en = ["right click"] + + +[[commands]] +id = "mouse_double_click" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["двойной клик", "дабл клик", "двойное нажатие"] +en = ["double click"] + + +[[commands]] +id = "mouse_scroll_down" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["промотай вниз", "прокрути вниз", "вниз промотай"] +en = ["scroll down"] + + +[[commands]] +id = "mouse_scroll_up" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["промотай вверх", "прокрути вверх", "наверх промотай"] +en = ["scroll up"] diff --git a/resources/commands/mouse/dispatch.lua b/resources/commands/mouse/dispatch.lua new file mode 100644 index 0000000..1107005 --- /dev/null +++ b/resources/commands/mouse/dispatch.lua @@ -0,0 +1,31 @@ +local cmd_id = jarvis.context.command_id +local helper = jarvis.context.command_path .. "\\_mouse_helper.ps1" + +local actions = { + mouse_left_click = "left", + mouse_right_click = "right", + mouse_middle_click = "middle", + mouse_double_click = "double", + mouse_scroll_up = "scroll_up", + mouse_scroll_down = "scroll_down", +} + +local action = actions[cmd_id] +if not action then + jarvis.audio.play_error() + return { chain = false } +end + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action %s', + helper, action +)) + +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "mouse " .. action .. " failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/net_info/command.toml b/resources/commands/net_info/command.toml new file mode 100644 index 0000000..3930da4 --- /dev/null +++ b/resources/commands/net_info/command.toml @@ -0,0 +1,53 @@ +# Network info — WiFi SSID, local IP, public IP via PowerShell + free service. +# Separate from existing `network` pack which likely covers different things. + +[[commands]] +id = "net.wifi_ssid" +type = "lua" +script = "wifi.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "к какой сети я подключен", + "какой wi-fi", + "какой вайфай", + "какая сеть", + "имя сети", +] +en = ["what wifi", "what network", "wifi name", "ssid"] + + +[[commands]] +id = "net.local_ip" +type = "lua" +script = "local_ip.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "мой локальный ip", + "мой ip", + "айпи адрес", + "локальный адрес", +] +en = ["my ip", "local ip", "ip address"] + + +[[commands]] +id = "net.public_ip" +type = "lua" +script = "public_ip.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = [ + "мой внешний ip", + "публичный ip", + "внешний айпи", + "какой у меня внешний", +] +en = ["public ip", "external ip", "what's my ip online"] diff --git a/resources/commands/net_info/local_ip.lua b/resources/commands/net_info/local_ip.lua new file mode 100644 index 0000000..55c0b50 --- /dev/null +++ b/resources/commands/net_info/local_ip.lua @@ -0,0 +1,18 @@ +-- Get first non-loopback IPv4 address via PowerShell Get-NetIPAddress. +local ps = "(Get-NetIPAddress -AddressFamily IPv4 | " .. + "Where-Object {$_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.*'} | " .. + "Select-Object -First 1).IPAddress" +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -Command "%s"', ps +)) + +if not res.success then + return jarvis.cmd.error("Не получилось узнать IP.") +end + +local ip = (res.stdout or ""):gsub("%s+", "") +if ip == "" then + return jarvis.cmd.not_found("Нет активного сетевого подключения.") +end + +return jarvis.cmd.ok("Локальный адрес: " .. ip .. ".") diff --git a/resources/commands/net_info/public_ip.lua b/resources/commands/net_info/public_ip.lua new file mode 100644 index 0000000..74968c2 --- /dev/null +++ b/resources/commands/net_info/public_ip.lua @@ -0,0 +1,12 @@ +-- Free public-IP services: api.ipify.org → plain text body. +local r = jarvis.http.get("https://api.ipify.org") +if not r or not r.ok then + return jarvis.cmd.error("Не получилось узнать внешний IP.") +end + +local ip = (r.body or ""):gsub("%s+", "") +if ip == "" then + return jarvis.cmd.error("Сервис вернул пустой ответ.") +end + +return jarvis.cmd.ok("Внешний адрес: " .. ip .. ".") diff --git a/resources/commands/net_info/wifi.lua b/resources/commands/net_info/wifi.lua new file mode 100644 index 0000000..a708fd1 --- /dev/null +++ b/resources/commands/net_info/wifi.lua @@ -0,0 +1,18 @@ +-- "Какая сеть" → `netsh wlan show interfaces` → grep SSID +local res = jarvis.system.exec('netsh wlan show interfaces') +if not res.success then + return jarvis.cmd.error("Не получилось получить данные о Wi-Fi.") +end + +local out = res.stdout or "" +local ssid = out:match("SSID%s+:%s+(.-)\r?\n") +-- Russian Windows: "SSID : MyNetwork" +-- The non-localised case: same format +if not ssid then ssid = out:match("Имя сети%s+:%s+(.-)\r?\n") end +ssid = ssid and ssid:gsub("^%s+", ""):gsub("%s+$", "") or "" + +if ssid == "" or ssid == "(нет)" or ssid == "(none)" then + return jarvis.cmd.not_found("Wi-Fi не подключен или адаптер выключен.") +end + +return jarvis.cmd.ok("Сеть: " .. ssid .. ".") diff --git a/resources/commands/network/command.toml b/resources/commands/network/command.toml new file mode 100644 index 0000000..6143093 --- /dev/null +++ b/resources/commands/network/command.toml @@ -0,0 +1,34 @@ +[[commands]] +id = "open_wifi_settings" +type = "lua" +script = "open.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["настройки wifi", "настройки вайфай", "wifi настройки", "сеть"] +en = ["wifi settings", "open wifi"] + + +[[commands]] +id = "open_bluetooth_settings" +type = "lua" +script = "open.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["настройки блютуз", "блютуз", "включи блютуз", "выключи блютуз"] +en = ["bluetooth settings", "open bluetooth", "toggle bluetooth"] + + +[[commands]] +id = "my_ip" +type = "lua" +script = "ip.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["мой ай пи", "мой ip", "какой у меня айпи", "айпишник"] +en = ["my ip", "what is my ip"] diff --git a/resources/commands/network/ip.lua b/resources/commands/network/ip.lua new file mode 100644 index 0000000..0e5635a --- /dev/null +++ b/resources/commands/network/ip.lua @@ -0,0 +1,24 @@ +local lang = jarvis.context.language + +local ps = [[ +$lan = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | + Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.PrefixOrigin -eq 'Manual' } | + Where-Object { $_.IPAddress -notmatch '^127\.' -and $_.IPAddress -notmatch '^169\.254\.' } | + Select-Object -First 1).IPAddress +if (-not $lan) { $lan = '(none)' } +$wan = try { (Invoke-RestMethod -Uri 'https://api.ipify.org?format=json' -TimeoutSec 5).ip } catch { 'unavailable' } +Write-Output ('LAN ' + $lan + ' WAN ' + $wan) +]] +local res = jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) +local out = (res.stdout or ""):gsub("[\r\n]+$", "") + +if out == "" then + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.system.notify("IP", out) +local say = out:gsub("LAN", lang == "ru" and "Локальный" or "LAN"):gsub("WAN", lang == "ru" and ". Внешний" or ". WAN") +jarvis.speak(say, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/network/open.lua b/resources/commands/network/open.lua new file mode 100644 index 0000000..f7334e3 --- /dev/null +++ b/resources/commands/network/open.lua @@ -0,0 +1,16 @@ +local cmd_id = jarvis.context.command_id +local lang = jarvis.context.language + +local uris = { + open_wifi_settings = "ms-settings:network-wifi", + open_bluetooth_settings = "ms-settings:bluetooth", +} + +local uri = uris[cmd_id] or "ms-settings:network" +jarvis.system.open(uri) +jarvis.system.notify( + cmd_id == "open_wifi_settings" and "Wi-Fi" or "Bluetooth", + lang == "ru" and "Открываю настройки" or "Opening settings" +) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/news/command.toml b/resources/commands/news/command.toml new file mode 100644 index 0000000..05f160a --- /dev/null +++ b/resources/commands/news/command.toml @@ -0,0 +1,22 @@ +[[commands]] +id = "news_headlines" +type = "lua" +script = "news.lua" +sandbox = "full" +timeout = 25000 + +[commands.phrases] +ru = [ + "что нового", + "новости", + "что в мире", + "последние новости", + "расскажи новости", + "топ новостей", +] +en = [ + "what's new", + "latest news", + "headlines", + "top news", +] diff --git a/resources/commands/news/news.lua b/resources/commands/news/news.lua new file mode 100644 index 0000000..ca51ac2 --- /dev/null +++ b/resources/commands/news/news.lua @@ -0,0 +1,82 @@ +local lang = jarvis.context.language + +local feed = lang == "en" + and "https://feeds.bbci.co.uk/news/rss.xml" + or "https://lenta.ru/rss/news" + +local res = jarvis.http.get(feed) +if not res.ok then + jarvis.system.notify("News", "Не получил RSS: " .. tostring(res.status)) + jarvis.audio.play_error() + return { chain = false } +end + +local titles = {} +for title in res.body:gmatch("%s*<!%[CDATA%[(.-)%]%]>%s*") do + if title ~= "" and title:lower():find("rss") == nil and title:lower():find("новости") ~= 1 then + table.insert(titles, title) + end + if #titles >= 7 then break end +end +if #titles == 0 then + for title in res.body:gmatch("%s*(.-)%s*") do + if title ~= "" then + table.insert(titles, title) + end + if #titles >= 7 then break end + end +end + +if #titles <= 1 then + jarvis.system.notify("News", lang == "ru" and "Не получилось распарсить" or "Could not parse feed") + jarvis.audio.play_error() + return { chain = false } +end + +local top = {} +for i = 2, math.min(6, #titles) do table.insert(top, titles[i]) end +local joined = table.concat(top, " | ") + +jarvis.system.clipboard.set(joined) +jarvis.system.notify(lang == "ru" and "Новости" or "News", joined:sub(1, 280)) + +local token = jarvis.system.env("GROQ_TOKEN") +local say_text +if token and token ~= "" then + local base = jarvis.system.env("GROQ_BASE_URL"); if not base or base == "" then base = "https://api.groq.com/openai/v1" end + local model = jarvis.system.env("GROQ_MODEL"); if not model or model == "" then model = "llama-3.3-70b-versatile" end + local sys = lang == "ru" + and "Ты диктор. Дам список заголовков новостей. Сжато перескажи 3 главные темы простым языком, 2-3 предложения суммарно. Не упоминай номера и не повторяй заголовки дословно." + or "You are a news anchor. Summarise the top 3 headlines in 2-3 sentences. No numbering, no verbatim repeats." + local payload = { + model = model, + messages = { + { role = "system", content = sys }, + { role = "user", content = joined }, + }, + max_tokens = 220, + temperature = 0.4, + } + local lr = jarvis.http.post_json(base .. "/chat/completions", payload, { Authorization = "Bearer " .. token }) + if lr.ok then + local c = (lr.body or ""):match('"content"%s*:%s*"(.-[^\\])"') + if c then + c = c:gsub('\\n', ' '):gsub('\\"', '"'):gsub('\\\\', '\\') + say_text = c + end + end +end + +if not say_text then + say_text = (lang == "ru" and "Главные новости: " or "Top news: ") .. (top[1] or "") .. ". " .. (top[2] or "") .. ". " .. (top[3] or "") +end + +local sapi = say_text:gsub("'", "''"):gsub("\r?\n", " ") +local ps = string.format( + [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], + sapi +) +jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) + +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/notes/add.lua b/resources/commands/notes/add.lua new file mode 100644 index 0000000..26f9327 --- /dev/null +++ b/resources/commands/notes/add.lua @@ -0,0 +1,56 @@ +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +local triggers = { + "запиши заметку", "запиши идею", "сделай заметку", "запомни идею", + "запиши", "запомни", + "write down", "take a note", "remember this", "note down", + "запиши ідею", "зроби нотатку", +} + +local body = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(body, t, 1, true) + if s == 1 then + body = body:sub(e + 1) + break + end +end +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + jarvis.system.notify( + lang == "ru" and "Заметка" or "Note", + lang == "ru" and "Что записать?" or "What to write?" + ) + jarvis.audio.play_error() + return { chain = false } +end + +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local notes_path = userprofile .. "\\Documents\\jarvis-notes.txt" + +local t = jarvis.context.time +local stamp = string.format("%s-%s-%s %s:%s", + t.year, t.month, t.day, t.hour, t.minute) + +local line = string.format("[%s] %s\n", stamp, body) + +local ok, err = pcall(function() + jarvis.fs.append(notes_path, line) +end) + +if not ok then + jarvis.log("error", "notes append failed: " .. tostring(err)) + jarvis.system.notify("Заметка", lang == "ru" and "Не удалось записать" or "Append failed") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.log("info", "note appended: " .. body) +jarvis.system.notify( + lang == "ru" and "Заметка записана" or "Note saved", + body:sub(1, 120) +) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/notes/command.toml b/resources/commands/notes/command.toml new file mode 100644 index 0000000..d672acc --- /dev/null +++ b/resources/commands/notes/command.toml @@ -0,0 +1,22 @@ +[[commands]] +id = "add_note" +type = "lua" +script = "add.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["запиши", "запиши заметку", "запиши идею", "запомни", "запомни идею", "сделай заметку"] +en = ["write down", "take a note", "remember this", "note down"] + + +[[commands]] +id = "open_notes" +type = "lua" +script = "open.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["покажи заметки", "открой заметки", "что я записал"] +en = ["show notes", "open notes"] diff --git a/resources/commands/notes/open.lua b/resources/commands/notes/open.lua new file mode 100644 index 0000000..6743ac4 --- /dev/null +++ b/resources/commands/notes/open.lua @@ -0,0 +1,16 @@ +local lang = jarvis.context.language +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local notes_path = userprofile .. "\\Documents\\jarvis-notes.txt" + +if not jarvis.fs.exists(notes_path) then + jarvis.system.notify( + lang == "ru" and "Заметки" or "Notes", + lang == "ru" and "Файл пуст или не создан" or "No notes yet" + ) + jarvis.audio.play_not_found() + return { chain = false } +end + +jarvis.system.open(notes_path) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/notif_queue/clear.lua b/resources/commands/notif_queue/clear.lua new file mode 100644 index 0000000..2ac0a1b --- /dev/null +++ b/resources/commands/notif_queue/clear.lua @@ -0,0 +1,13 @@ +local all = jarvis.memory.all() +local count = 0 +for _, rec in ipairs(all) do + if rec.key:sub(1, 6) == "notif." then + jarvis.memory.forget(rec.key) + count = count + 1 + end +end + +if count == 0 then + return jarvis.cmd.ok("Уведомлений и так нет.") +end +return jarvis.cmd.ok(string.format("Очистил %d уведомлений.", count)) diff --git a/resources/commands/notif_queue/command.toml b/resources/commands/notif_queue/command.toml new file mode 100644 index 0000000..73d4ad5 --- /dev/null +++ b/resources/commands/notif_queue/command.toml @@ -0,0 +1,35 @@ +# Notification queue — records what Jarvis said while you were away. +# Used by scheduler/macros/llm to log fired events; speak the digest on demand. + +[[commands]] +id = "notif.what_missed" +type = "lua" +script = "missed.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "что я пропустил", + "что было пока меня не было", + "какие уведомления", + "пропущенные", + "что нового", +] +en = ["what did I miss", "missed notifications"] + + +[[commands]] +id = "notif.clear" +type = "lua" +script = "clear.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "очисти уведомления", + "забудь уведомления", + "сбрось пропущенное", +] +en = ["clear notifications"] diff --git a/resources/commands/notif_queue/missed.lua b/resources/commands/notif_queue/missed.lua new file mode 100644 index 0000000..fca579e --- /dev/null +++ b/resources/commands/notif_queue/missed.lua @@ -0,0 +1,24 @@ +-- Notification queue uses memory keys "notif.0".."notif.N" (rolling, max 30). +-- Producers (scheduler/macros/llm fallback) call jarvis.memory.remember "notif." elsewhere. +-- Here we just enumerate and speak. +local all = jarvis.memory.all() +local notifs = {} +for _, rec in ipairs(all) do + if rec.key:sub(1, 6) == "notif." then + table.insert(notifs, { ts = rec.last_used_at or 0, key = rec.key, value = rec.value }) + end +end + +if #notifs == 0 then + return jarvis.cmd.ok("Ничего не пропустил.") +end + +-- Sort by timestamp desc +table.sort(notifs, function(a, b) return a.ts > b.ts end) + +local sample = math.min(#notifs, 5) +local line = string.format("Пропущено %d уведомлений. ", #notifs) +for i = 1, sample do + line = line .. notifs[i].value .. ". " +end +return jarvis.cmd.ok(line) diff --git a/resources/commands/now_playing/command.toml b/resources/commands/now_playing/command.toml new file mode 100644 index 0000000..1729a3e --- /dev/null +++ b/resources/commands/now_playing/command.toml @@ -0,0 +1,23 @@ +# Windows Media Session — what's currently playing. +# Uses Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager +# via PowerShell. Works with Spotify, YouTube, Foobar, Yandex Music, anything +# that exposes SMTC. + +[[commands]] +id = "now_playing" +type = "lua" +script = "now.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "что играет", + "что сейчас играет", + "что за музыка", + "что за песня", + "какой трек", + "какая песня", + "что слушаю", +] +en = ["what's playing", "current song", "now playing"] diff --git a/resources/commands/now_playing/now.lua b/resources/commands/now_playing/now.lua new file mode 100644 index 0000000..dc98a9a --- /dev/null +++ b/resources/commands/now_playing/now.lua @@ -0,0 +1,46 @@ +-- "Что играет" — query Windows SMTC. +-- Requires Windows 10 1803+ (Media Session API). +local ps = [[ +[Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager,Windows.Media.Control,ContentType=WindowsRuntime] | Out-Null +$mgr = [Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync() +while ($mgr.Status -eq 0) { Start-Sleep -Milliseconds 50 } +$session = $mgr.GetResults().GetCurrentSession() +if (-not $session) { Write-Output 'NONE'; exit } +$propsTask = $session.TryGetMediaPropertiesAsync() +while ($propsTask.Status -eq 0) { Start-Sleep -Milliseconds 50 } +$props = $propsTask.GetResults() +$artist = $props.Artist +$title = $props.Title +if ([string]::IsNullOrEmpty($artist) -and [string]::IsNullOrEmpty($title)) { + Write-Output 'EMPTY' + exit +} +Write-Output ("{0}|{1}" -f $artist, $title) +]] + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', + ps:gsub('"', '\\"'):gsub("\r?\n", "; ") +)) + +if not res.success then + return jarvis.cmd.error("Не получилось получить данные.") +end + +local out = (res.stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") +if out == "NONE" then + return jarvis.cmd.not_found("Сейчас ничего не играет.") +end +if out == "EMPTY" or out == "|" then + return jarvis.cmd.not_found("Что-то играет, но без названия.") +end + +local artist, title = out:match("^([^|]*)|(.*)$") +if not title or title == "" then + return jarvis.cmd.not_found("Трек без названия.") +end + +if artist and artist ~= "" then + return jarvis.cmd.ok(string.format("Сейчас играет: %s — %s.", artist, title)) +end +return jarvis.cmd.ok(string.format("Сейчас играет: %s.", title)) diff --git a/resources/commands/ocr/command.toml b/resources/commands/ocr/command.toml new file mode 100644 index 0000000..679c6a1 --- /dev/null +++ b/resources/commands/ocr/command.toml @@ -0,0 +1,21 @@ +[[commands]] +id = "read_screen" +type = "lua" +script = "read_screen.lua" +sandbox = "full" +timeout = 30000 + +[commands.phrases] +ru = [ + "прочитай экран", + "что на экране", + "распознай текст с экрана", + "что написано на экране", + "сними текст с экрана", +] +en = [ + "read screen", + "what is on screen", + "ocr screen", + "extract text from screen", +] diff --git a/resources/commands/ocr/read_screen.lua b/resources/commands/ocr/read_screen.lua new file mode 100644 index 0000000..1c6f753 --- /dev/null +++ b/resources/commands/ocr/read_screen.lua @@ -0,0 +1,82 @@ +local lang = jarvis.context.language + +local temp = jarvis.system.env("TEMP") or jarvis.system.env("TMP") or "C:\\Windows\\Temp" +local ts = jarvis.context.time.year .. jarvis.context.time.month .. jarvis.context.time.day + .. "_" .. jarvis.context.time.hour .. jarvis.context.time.minute .. jarvis.context.time.second +local img = temp .. "\\jarvis_ocr_" .. ts .. ".png" + +local capture_ps = string.format( + [[Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object Drawing.Bitmap $b.Width,$b.Height; $g=[Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); $bmp.Save('%s'); $g.Dispose(); $bmp.Dispose()]], + img:gsub("\\", "\\\\") +) +local capture_cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', capture_ps:gsub('"', '\\"')) +local cap = jarvis.system.exec(capture_cmd) + +if not cap.success or not jarvis.fs.exists(img) then + jarvis.log("error", "screen capture failed: " .. tostring(cap.stderr)) + jarvis.system.notify("OCR", lang == "ru" and "Не смог снять экран" or "Could not capture screen") + jarvis.audio.play_error() + return { chain = false } +end + +local tesseract_paths = { + "tesseract.exe", + "C:\\Program Files\\Tesseract-OCR\\tesseract.exe", + "C:\\Program Files (x86)\\Tesseract-OCR\\tesseract.exe", +} + +local tess = nil +for _, p in ipairs(tesseract_paths) do + local check + if p == "tesseract.exe" then + check = jarvis.system.exec("where tesseract.exe") + else + check = { success = jarvis.fs.exists(p), stdout = p } + end + if check.success and (check.stdout or ""):gsub("[\r\n]+$", "") ~= "" then + tess = p == "tesseract.exe" and "tesseract.exe" or '"' .. p .. '"' + break + end +end + +if not tess then + jarvis.log("error", "tesseract not found") + jarvis.system.notify( + "OCR", + lang == "ru" + and "Tesseract не установлен. Установи: winget install UB-Mannheim.TesseractOCR" + or "Tesseract not installed. Install: winget install UB-Mannheim.TesseractOCR" + ) + jarvis.audio.play_error() + return { chain = false } +end + +local langs = lang == "en" and "eng" or "rus+eng" +local ocr_cmd = string.format('%s "%s" - -l %s', tess, img, langs) +local ocr = jarvis.system.exec(ocr_cmd) + +if not ocr.success then + jarvis.log("error", "tesseract failed: " .. tostring(ocr.stderr):sub(1, 200)) + jarvis.system.notify("OCR", lang == "ru" and "Tesseract упал" or "Tesseract failed") + jarvis.audio.play_error() + return { chain = false } +end + +local text = (ocr.stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") +if text == "" then + jarvis.system.notify("OCR", lang == "ru" and "Текста не найдено" or "No text found") + jarvis.audio.play_not_found() + return { chain = false } +end + +jarvis.system.clipboard.set(text) +jarvis.log("info", "OCR result (" .. #text .. " bytes) copied to clipboard") + +local preview = text:gsub("\r?\n", " "):sub(1, 200) +if #text > 200 then preview = preview .. "..." end +jarvis.system.notify( + lang == "ru" and "Текст с экрана" or "Screen text", + preview +) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/outlook/_outlook.ps1 b/resources/commands/outlook/_outlook.ps1 new file mode 100644 index 0000000..2ea135c --- /dev/null +++ b/resources/commands/outlook/_outlook.ps1 @@ -0,0 +1,184 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet("unread_count","latest","send_clipboard","summarize_inbox")] + [string]$Action, + + [string]$Recipient = "", + [string]$Body = "", + [int]$Limit = 5 +) + +$ErrorActionPreference = 'Stop' +$OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# Output protocol (parsed by Lua scripts): +# First line: "OK" or "ERR:" or "ERR::" +# Subsequent lines depend on Action: +# unread_count -> "" +# latest -> "FROM\t" / "SUBJECT\t" / "PREVIEW\t" +# send_clipboard -> "TO\t" / "ADDR\t" / "SUBJECT\t" +# summarize_inbox -> repeated "FROM\t\tSUBJECT\t" +# +# All multiline strings get newlines collapsed to spaces before printing. + +function Clean-Line([string]$s) { + if (-not $s) { return "" } + $s = $s -replace "`r", " " -replace "`n", " " -replace "`t", " " + return ($s -replace '\s+', ' ').Trim() +} + +function Fail($code, $msg = "") { + if ($msg) { + Write-Output ("ERR:{0}:{1}" -f $code, (Clean-Line $msg)) + } else { + Write-Output ("ERR:{0}" -f $code) + } + exit 0 +} + +function Get-OutlookSession { + try { + $ol = New-Object -ComObject Outlook.Application + $ns = $ol.GetNamespace("MAPI") + return @{ App = $ol; Ns = $ns } + } catch { + Fail "outlook_unavailable" $_.Exception.Message + } +} + +function Strip-Html([string]$s) { + if (-not $s) { return "" } + $s = [regex]::Replace($s, '<[^>]+>', ' ') + $s = $s -replace ' ', ' ' -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", "'" + return Clean-Line $s +} + +function Resolve-Recipient([object]$ns, [string]$query) { + if ([string]::IsNullOrWhiteSpace($query)) { return $null } + try { + $r = $ns.CreateRecipient($query) + $null = $r.Resolve() + if ($r.Resolved) { + $addr = $null + try { $addr = $r.AddressEntry.GetExchangeUser().PrimarySmtpAddress } catch { } + if (-not $addr) { + try { $addr = $r.AddressEntry.Address } catch { } + } + if (-not $addr) { $addr = $r.Name } + return @{ Name = $r.Name; Address = $addr } + } + } catch { } + return $null +} + +switch ($Action) { + "unread_count" { + $s = Get-OutlookSession + try { + $inbox = $s.Ns.GetDefaultFolder(6) + $count = 0 + try { $count = [int]$inbox.UnReadItemCount } catch { } + if (-not $count) { + $count = ($inbox.Items | Where-Object { $_.UnRead -eq $true }).Count + } + Write-Output "OK" + Write-Output ([int]$count).ToString() + } catch { + Fail "inbox_failed" $_.Exception.Message + } + } + + "latest" { + $s = Get-OutlookSession + try { + $inbox = $s.Ns.GetDefaultFolder(6) + $items = $inbox.Items + $items.Sort("[ReceivedTime]", $true) + $latest = $null + foreach ($it in $items) { + if ($it.Class -eq 43) { $latest = $it; break } # olMail = 43 + } + if (-not $latest) { Fail "empty_inbox" } + $fromName = "" + try { $fromName = $latest.SenderName } catch { } + if (-not $fromName) { try { $fromName = $latest.SenderEmailAddress } catch { } } + $subject = "" + try { $subject = $latest.Subject } catch { } + $body = "" + try { $body = $latest.Body } catch { } + if (-not $body) { + try { $body = Strip-Html $latest.HTMLBody } catch { } + } else { + $body = Clean-Line $body + } + if ($body.Length -gt 200) { $body = $body.Substring(0, 200) + "..." } + Write-Output "OK" + Write-Output ("FROM`t{0}" -f (Clean-Line $fromName)) + Write-Output ("SUBJECT`t{0}" -f (Clean-Line $subject)) + Write-Output ("PREVIEW`t{0}" -f $body) + } catch { + Fail "latest_failed" $_.Exception.Message + } + } + + "send_clipboard" { + if ([string]::IsNullOrWhiteSpace($Recipient)) { Fail "no_recipient" } + $s = Get-OutlookSession + try { + $resolved = Resolve-Recipient $s.Ns $Recipient + if (-not $resolved) { Fail "unresolved" $Recipient } + $bodyText = if ($Body) { $Body } else { "" } + $subject = "" + if ($bodyText) { + $firstLine = ($bodyText -split "`r?`n", 2)[0].Trim() + if ($firstLine.Length -gt 60) { + $subject = $firstLine.Substring(0, 60).Trim() + } else { + $subject = $firstLine + } + } + if ([string]::IsNullOrWhiteSpace($subject)) { $subject = "From J.A.R.V.I.S." } + + $mail = $s.App.CreateItem(0) + $mail.To = $resolved.Address + $mail.Subject = $subject + $mail.Body = $bodyText + $mail.Send() + Write-Output "OK" + Write-Output ("TO`t{0}" -f (Clean-Line $resolved.Name)) + Write-Output ("ADDR`t{0}" -f (Clean-Line $resolved.Address)) + Write-Output ("SUBJECT`t{0}" -f (Clean-Line $subject)) + } catch { + Fail "send_failed" $_.Exception.Message + } + } + + "summarize_inbox" { + $s = Get-OutlookSession + try { + $inbox = $s.Ns.GetDefaultFolder(6) + $items = $inbox.Items + $items.Sort("[ReceivedTime]", $true) + $taken = 0 + $lines = @() + foreach ($it in $items) { + if ($taken -ge $Limit) { break } + if ($it.Class -ne 43) { continue } + $unread = $false + try { $unread = [bool]$it.UnRead } catch { } + if (-not $unread) { continue } + $fromName = "" + try { $fromName = $it.SenderName } catch { } + $subj = "" + try { $subj = $it.Subject } catch { } + $lines += ("FROM`t{0}`tSUBJECT`t{1}" -f (Clean-Line $fromName), (Clean-Line $subj)) + $taken += 1 + } + Write-Output "OK" + foreach ($l in $lines) { Write-Output $l } + } catch { + Fail "summary_failed" $_.Exception.Message + } + } +} diff --git a/resources/commands/outlook/command.toml b/resources/commands/outlook/command.toml new file mode 100644 index 0000000..720ff98 --- /dev/null +++ b/resources/commands/outlook/command.toml @@ -0,0 +1,91 @@ +[[commands]] +id = "outlook_unread_count" +type = "lua" +script = "unread_count.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "сколько у меня непрочитанных писем", + "сколько непрочитанных писем", + "проверь почту", + "проверь outlook", + "сколько новых писем", +] +en = [ + "check unread mail", + "how many unread emails", + "check my inbox", + "unread email count", + "any new mail", +] + + +[[commands]] +id = "outlook_latest" +type = "lua" +script = "latest.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "прочитай последнее письмо", + "что в последнем письме", + "последнее письмо", + "покажи последнее письмо", +] +en = [ + "read last email", + "read latest email", + "what is the last email", + "show me the latest email", +] + + +[[commands]] +id = "outlook_send_clipboard" +type = "lua" +script = "send_clipboard.lua" +sandbox = "full" +timeout = 20000 + +[commands.phrases] +ru = [ + "отправь письмо {name}", + "отправь почту {name}", + "напиши письмо {name}", + "отправь это {name}", +] +en = [ + "email this to {name}", + "send email to {name}", + "send this email to {name}", + "mail this to {name}", +] + +[commands.slots.name] +entity = "person name" + + +[[commands]] +id = "outlook_summarize_inbox" +type = "lua" +script = "summarize_inbox.lua" +sandbox = "full" +timeout = 30000 + +[commands.phrases] +ru = [ + "коротко расскажи про инбокс", + "расскажи что в инбоксе", + "сделай саммари инбокса", + "что нового в почте", +] +en = [ + "summarize inbox", + "summarise my inbox", + "tell me about my inbox", + "what is new in my mail", +] diff --git a/resources/commands/outlook/latest.lua b/resources/commands/outlook/latest.lua new file mode 100644 index 0000000..7bfcc09 --- /dev/null +++ b/resources/commands/outlook/latest.lua @@ -0,0 +1,68 @@ +-- Read the most recent email in the default Inbox: speak FROM + SUBJECT + +-- a 1-sentence preview (~200 chars, HTML stripped) of the body. + +local lang = jarvis.context.language +local helper = jarvis.context.command_path .. "\\_outlook.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action latest', + helper +) + +local res = jarvis.system.exec(cmd) +local out = (res.stdout or ""):gsub("\r", "") + +if not res.success or out == "" then + jarvis.log("error", "outlook latest exec failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local lines = {} +for line in out:gmatch("[^\n]+") do + table.insert(lines, line) +end + +local status = lines[1] or "" +if status:sub(1, 3) == "ERR" then + if status:find("empty_inbox", 1, true) then + return jarvis.cmd.not_found(lang == "ru" + and "Входящие пусты." + or "Inbox is empty.") + end + jarvis.log("warn", "outlook latest: " .. status) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local from, subject, preview = "", "", "" +for i = 2, #lines do + local key, val = lines[i]:match("^([A-Z]+)\t(.*)$") + if key == "FROM" then from = val or "" end + if key == "SUBJECT" then subject = val or "" end + if key == "PREVIEW" then preview = val or "" end +end + +if from == "" and subject == "" then + return jarvis.cmd.error(lang == "ru" and "Не получилось прочитать письмо." or "Could not read the email.") +end + +local speech +if lang == "ru" then + speech = string.format("Последнее письмо от %s. Тема: %s. %s", + from ~= "" and from or "неизвестного отправителя", + subject ~= "" and subject or "без темы", + preview) +else + speech = string.format("Latest email from %s. Subject: %s. %s", + from ~= "" and from or "unknown sender", + subject ~= "" and subject or "no subject", + preview) +end + +jarvis.system.notify( + lang == "ru" and "Последнее письмо" or "Latest email", + string.format("%s\n%s\n%s", from, subject, preview) +) +return jarvis.cmd.ok(speech) diff --git a/resources/commands/outlook/send_clipboard.lua b/resources/commands/outlook/send_clipboard.lua new file mode 100644 index 0000000..94ecb53 --- /dev/null +++ b/resources/commands/outlook/send_clipboard.lua @@ -0,0 +1,103 @@ +-- Send clipboard contents as an email to {name} via Outlook COM. The recipient +-- is resolved against the Global Address List + Contacts (Outlook's Recipient +-- .Resolve API). Subject = first line of the body truncated to 60 chars, or +-- "From J.A.R.V.I.S." for an empty clipboard. + +local lang = jarvis.context.language +local slots = jarvis.context.slots or {} +local recipient = slots.name or "" +recipient = tostring(recipient):gsub("^%s+", ""):gsub("%s+$", "") + +if recipient == "" then + return jarvis.cmd.not_found(lang == "ru" + and "Кому отправить? Назови имя." + or "Whom to send to? Please name them.") +end + +local body = jarvis.system.clipboard.get() or "" +body = body:gsub("^%s+", ""):gsub("%s+$", "") + +-- Write the body to a temp file so we don't have to escape it through the +-- PowerShell command-line. The PS helper reads it back via -Body parameter +-- value (passed as an arg). For very large bodies (>32 KB) the cmd-line +-- approach would break; in that case we read the file from inside PS. +local temp = jarvis.system.env("TEMP") or jarvis.system.env("TMP") or "C:\\Windows\\Temp" +local ts = jarvis.context.time.year .. jarvis.context.time.month .. jarvis.context.time.day + .. "_" .. jarvis.context.time.hour .. jarvis.context.time.minute .. jarvis.context.time.second +local body_path = temp .. "\\jarvis_outlook_body_" .. ts .. ".txt" +jarvis.fs.write(body_path, body) + +-- Pass body via the file: the helper reads $Body if set, else falls back to +-- reading the saved file. To keep the helper simple we just inline the body +-- through -Body only when small; otherwise we still pass via -Body but quote +-- the path and let PS read it. We use the latter: PS reads its arg literally. +-- We instead encode the body so passing through args is safe. +local helper = jarvis.context.command_path .. "\\_outlook.ps1" + +-- Build a single-line PowerShell expression that reads the body from the +-- temp file and invokes the helper. This avoids any shell-escape issues +-- with quotes/newlines in the clipboard. +local body_path_ps = body_path:gsub("'", "''") +local recipient_ps = recipient:gsub("'", "''") +local helper_ps = helper:gsub("'", "''") +local ps_expr = string.format( + "$b = Get-Content -LiteralPath '%s' -Raw -Encoding UTF8; if (-not $b) { $b = '' } else { $b = $b.TrimEnd() }; & '%s' -Action send_clipboard -Recipient '%s' -Body $b", + body_path_ps, helper_ps, recipient_ps +) +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', + ps_expr:gsub('"', '\\"') +) + +local res = jarvis.system.exec(cmd) +jarvis.fs.remove(body_path) + +local out = (res.stdout or ""):gsub("\r", "") + +if not res.success or out == "" then + jarvis.log("error", "outlook send_clipboard exec failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local lines = {} +for line in out:gmatch("[^\n]+") do table.insert(lines, line) end + +local status = lines[1] or "" +if status:sub(1, 3) == "ERR" then + if status:find("unresolved", 1, true) then + return jarvis.cmd.not_found(lang == "ru" + and (string.format("Не нашёл контакт «%s».", recipient)) + or (string.format("Could not find contact '%s'.", recipient))) + elseif status:find("no_recipient", 1, true) then + return jarvis.cmd.not_found(lang == "ru" + and "Кому отправить? Назови имя." + or "Whom to send to? Please name them.") + end + jarvis.log("warn", "outlook send_clipboard: " .. status) + return jarvis.cmd.error(lang == "ru" + and "Не получилось отправить — Outlook отказал." + or "Send failed — Outlook refused.") +end + +local to_name, addr, subject = "", "", "" +for i = 2, #lines do + local key, val = lines[i]:match("^([A-Z]+)\t(.*)$") + if key == "TO" then to_name = val or "" end + if key == "ADDR" then addr = val or "" end + if key == "SUBJECT" then subject = val or "" end +end + +local speech +if lang == "ru" then + speech = string.format("Отправил %s. Тема: %s.", to_name ~= "" and to_name or recipient, subject) +else + speech = string.format("Sent to %s. Subject: %s.", to_name ~= "" and to_name or recipient, subject) +end + +jarvis.system.notify( + lang == "ru" and "Письмо отправлено" or "Email sent", + string.format("%s <%s>\n%s", to_name, addr, subject) +) +return jarvis.cmd.ok(speech) diff --git a/resources/commands/outlook/summarize_inbox.lua b/resources/commands/outlook/summarize_inbox.lua new file mode 100644 index 0000000..ae8f76e --- /dev/null +++ b/resources/commands/outlook/summarize_inbox.lua @@ -0,0 +1,79 @@ +-- Summarize the last few unread emails into one paragraph via the LLM. +-- Pulls FROM+SUBJECT of up to 5 unread items, then asks the model for a +-- short paragraph in the user's language. + +local lang = jarvis.context.language +local helper = jarvis.context.command_path .. "\\_outlook.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action summarize_inbox -Limit 5', + helper +) + +local res = jarvis.system.exec(cmd) +local out = (res.stdout or ""):gsub("\r", "") + +if not res.success or out == "" then + jarvis.log("error", "outlook summarize_inbox exec failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local lines = {} +for line in out:gmatch("[^\n]+") do table.insert(lines, line) end + +local status = lines[1] or "" +if status:sub(1, 3) == "ERR" then + jarvis.log("warn", "outlook summarize_inbox: " .. status) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local emails = {} +for i = 2, #lines do + local from, subject = lines[i]:match("^FROM\t(.-)\tSUBJECT\t(.*)$") + if from then + table.insert(emails, { from = from, subject = subject or "" }) + end +end + +if #emails == 0 then + return jarvis.cmd.not_found(lang == "ru" + and "Непрочитанных писем нет." + or "No unread emails.") +end + +-- Build the prompt +local list_str = "" +for i, m in ipairs(emails) do + list_str = list_str .. string.format("%d. From: %s | Subject: %s\n", i, m.from, m.subject) +end + +local sys_prompt = lang == "ru" + and "Ты помощник по почте. Кратко опиши, что лежит в инбоксе, ОДНИМ абзацем (2-4 предложения). Сгруппируй по темам/отправителям если возможно. Без преамбулы, без перечисления списком." + or "You are an email assistant. Briefly describe the inbox in ONE paragraph (2-4 sentences). Group by topic/sender when possible. No preamble, no bullet list." + +local user_prompt = (lang == "ru" and "Непрочитанные письма:\n" or "Unread emails:\n") .. list_str + +local reply = jarvis.llm( + { { role = "system", content = sys_prompt }, + { role = "user", content = user_prompt } }, + { max_tokens = 250, temperature = 0.4 } +) + +if not reply or reply == "" then + -- Fallback: simple flat enumeration when LLM is unavailable. + local parts = {} + for _, m in ipairs(emails) do + table.insert(parts, string.format("%s — %s", m.from, m.subject)) + end + local fallback = (lang == "ru" + and string.format("Непрочитанных: %d. ", #emails) + or string.format("Unread: %d. ", #emails)) .. table.concat(parts, "; ") .. "." + jarvis.system.notify(lang == "ru" and "Инбокс" or "Inbox", fallback) + return jarvis.cmd.ok(fallback) +end + +jarvis.system.notify(lang == "ru" and "Инбокс" or "Inbox", reply) +return jarvis.cmd.ok(reply) diff --git a/resources/commands/outlook/unread_count.lua b/resources/commands/outlook/unread_count.lua new file mode 100644 index 0000000..6d048ab --- /dev/null +++ b/resources/commands/outlook/unread_count.lua @@ -0,0 +1,55 @@ +-- Voice-controlled Outlook: report number of unread emails in default Inbox. +-- The PowerShell helper talks to Outlook via COM; if Outlook is closed or +-- COM fails we degrade gracefully with an explanatory message. + +local lang = jarvis.context.language +local helper = jarvis.context.command_path .. "\\_outlook.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action unread_count', + helper +) + +local res = jarvis.system.exec(cmd) +local out = (res.stdout or ""):gsub("\r", "") + +if not res.success or out == "" then + jarvis.log("error", "outlook unread_count exec failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local first_line, rest = out:match("^([^\n]*)\n?(.*)$") +first_line = first_line or "" + +if first_line:sub(1, 3) == "ERR" then + jarvis.log("warn", "outlook unread_count: " .. first_line) + return jarvis.cmd.error(lang == "ru" + and "Outlook недоступен — запусти Outlook сначала." + or "Outlook is unavailable — start Outlook first.") +end + +local count_str = (rest or ""):match("(%d+)") or "0" +local count = tonumber(count_str) or 0 + +local speech +if lang == "ru" then + if count == 0 then + speech = "Непрочитанных писем нет." + elseif count == 1 then + speech = "Одно непрочитанное письмо." + else + speech = string.format("Непрочитанных писем: %d.", count) + end +else + if count == 0 then + speech = "No unread emails." + elseif count == 1 then + speech = "One unread email." + else + speech = string.format("You have %d unread emails.", count) + end +end + +jarvis.system.notify(lang == "ru" and "Outlook" or "Outlook", speech) +return jarvis.cmd.ok(speech) diff --git a/resources/commands/output_device/_audio_devices.ps1 b/resources/commands/output_device/_audio_devices.ps1 new file mode 100644 index 0000000..2cf76e9 --- /dev/null +++ b/resources/commands/output_device/_audio_devices.ps1 @@ -0,0 +1,195 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet("list","set","next","current")] + [string]$Action, + + [int]$Index = -1 +) + +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace JarvisAudio +{ + [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDeviceEnumerator + { + [PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices); + [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice device); + [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); + } + + [Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDeviceCollection + { + [PreserveSig] int GetCount(out int count); + [PreserveSig] int Item(int idx, out IMMDevice device); + } + + [Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDevice + { + [PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object iface); + [PreserveSig] int OpenPropertyStore(int access, out IPropertyStore store); + [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); + [PreserveSig] int GetState(out int state); + } + + [Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IPropertyStore + { + [PreserveSig] int GetCount(out int count); + [PreserveSig] int GetAt(int idx, out PROPERTYKEY key); + [PreserveSig] int GetValue(ref PROPERTYKEY key, out PROPVARIANT value); + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROPERTYKEY + { + public Guid formatId; + public int propertyId; + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROPVARIANT + { + public ushort vt; + public ushort r1; + public ushort r2; + public ushort r3; + public IntPtr p; + public IntPtr p2; + } + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + public class MMDeviceEnumerator {} + + [Guid("F8679F50-850A-41CF-9C72-430F290290C8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IPolicyConfig + { + [PreserveSig] int GetMixFormat(); + [PreserveSig] int GetDeviceFormat(); + [PreserveSig] int ResetDeviceFormat(); + [PreserveSig] int SetDeviceFormat(); + [PreserveSig] int GetProcessingPeriod(); + [PreserveSig] int SetProcessingPeriod(); + [PreserveSig] int GetShareMode(); + [PreserveSig] int SetShareMode(); + [PreserveSig] int GetPropertyValue(); + [PreserveSig] int SetPropertyValue(); + [PreserveSig] int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string deviceId, uint role); + [PreserveSig] int SetEndpointVisibility(); + } + + [ComImport, Guid("870AF99C-171D-4F9E-AF0D-E63DF40C2BC9")] + public class PolicyConfigClient {} + + public class Device + { + public string Id { get; set; } + public string Name { get; set; } + } + + public static class Manager + { + const int eRender = 0; + const int DEVICE_STATE_ACTIVE = 1; + + static PROPERTYKEY PKEY_Device_FriendlyName = new PROPERTYKEY { + formatId = new Guid("a45c254e-df1c-4efd-8020-67d146a850e0"), + propertyId = 14 + }; + + public static List ListRender() + { + var enumr = (IMMDeviceEnumerator)new MMDeviceEnumerator(); + IMMDeviceCollection coll; + int hr = enumr.EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, out coll); + if (hr != 0) throw new Exception("EnumAudioEndpoints failed: 0x" + hr.ToString("X")); + + int count; + coll.GetCount(out count); + + var result = new List(); + for (int i = 0; i < count; i++) + { + IMMDevice dev; + coll.Item(i, out dev); + string id; + dev.GetId(out id); + + IPropertyStore store; + dev.OpenPropertyStore(0, out store); + + PROPVARIANT v; + store.GetValue(ref PKEY_Device_FriendlyName, out v); + string name = v.p != IntPtr.Zero ? Marshal.PtrToStringUni(v.p) : "(unknown)"; + + result.Add(new Device { Id = id, Name = name }); + } + return result; + } + + public static string GetDefaultRenderId() + { + var enumr = (IMMDeviceEnumerator)new MMDeviceEnumerator(); + IMMDevice dev; + int hr = enumr.GetDefaultAudioEndpoint(eRender, 0, out dev); // eConsole = 0 + if (hr != 0) return null; + string id; + dev.GetId(out id); + return id; + } + + public static void SetDefault(string deviceId) + { + var cfg = (IPolicyConfig)new PolicyConfigClient(); + cfg.SetDefaultEndpoint(deviceId, 0); // eConsole + cfg.SetDefaultEndpoint(deviceId, 1); // eMultimedia + cfg.SetDefaultEndpoint(deviceId, 2); // eCommunications + } + } +} +'@ -Language CSharp + +$devs = [JarvisAudio.Manager]::ListRender() + +switch ($Action) { + "list" { + for ($i = 0; $i -lt $devs.Count; $i++) { + Write-Output ("{0}`t{1}" -f $i, $devs[$i].Name) + } + } + "current" { + $cur = [JarvisAudio.Manager]::GetDefaultRenderId() + for ($i = 0; $i -lt $devs.Count; $i++) { + if ($devs[$i].Id -eq $cur) { + Write-Output ("{0}`t{1}" -f $i, $devs[$i].Name) + return + } + } + Write-Output "-1`t(unknown)" + } + "set" { + if ($Index -lt 0 -or $Index -ge $devs.Count) { + Write-Error "Index out of range: $Index (have $($devs.Count) devices)" + exit 2 + } + [JarvisAudio.Manager]::SetDefault($devs[$Index].Id) + Write-Output ("OK`t{0}" -f $devs[$Index].Name) + } + "next" { + $cur = [JarvisAudio.Manager]::GetDefaultRenderId() + $curIdx = -1 + for ($i = 0; $i -lt $devs.Count; $i++) { + if ($devs[$i].Id -eq $cur) { $curIdx = $i; break } + } + $nextIdx = ($curIdx + 1) % $devs.Count + [JarvisAudio.Manager]::SetDefault($devs[$nextIdx].Id) + Write-Output ("OK`t{0}" -f $devs[$nextIdx].Name) + } +} diff --git a/resources/commands/output_device/command.toml b/resources/commands/output_device/command.toml new file mode 100644 index 0000000..c26511c --- /dev/null +++ b/resources/commands/output_device/command.toml @@ -0,0 +1,45 @@ +[[commands]] +id = "list_output_devices" +type = "lua" +script = "list_devices.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "какие устройства вывода", + "покажи устройства", + "список устройств", + "какие наушники", + "какие колонки", +] +en = [ + "list output devices", + "show audio devices", + "list speakers", + "what audio devices", +] + + +[[commands]] +id = "next_output_device" +type = "lua" +script = "next_device.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "переключи устройство", + "переключи звук", + "следующее устройство", + "смени вывод", + "переключи на наушники", + "переключи на колонки", +] +en = [ + "switch audio device", + "next audio device", + "switch output", + "switch speakers", +] diff --git a/resources/commands/output_device/list_devices.lua b/resources/commands/output_device/list_devices.lua new file mode 100644 index 0000000..aefc6b2 --- /dev/null +++ b/resources/commands/output_device/list_devices.lua @@ -0,0 +1,31 @@ +local lang = jarvis.context.language +local helper = jarvis.context.command_path .. "\\_audio_devices.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action list', + helper +) + +local res = jarvis.system.exec(cmd) +if not res.success then + jarvis.log("error", "list devices failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() + return { chain = false } +end + +local lines = {} +for line in (res.stdout or ""):gmatch("[^\r\n]+") do + if line ~= "" then table.insert(lines, line) end +end + +local title = lang == "ru" and "Устройства вывода" or "Output devices" +local body +if #lines == 0 then + body = lang == "ru" and "Активных устройств не найдено" or "No active devices" +else + body = table.concat(lines, "\n") +end + +jarvis.system.notify(title, body) +jarvis.log("info", "audio devices: " .. body) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/output_device/next_device.lua b/resources/commands/output_device/next_device.lua new file mode 100644 index 0000000..852462a --- /dev/null +++ b/resources/commands/output_device/next_device.lua @@ -0,0 +1,22 @@ +local lang = jarvis.context.language +local helper = jarvis.context.command_path .. "\\_audio_devices.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action next', + helper +) + +local res = jarvis.system.exec(cmd) +if not res.success then + jarvis.log("error", "next device failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() + return { chain = false } +end + +local out = (res.stdout or ""):gsub("[\r\n]+$", "") +local name = out:match("^OK\t(.+)$") or out + +local title = lang == "ru" and "Аудио переключено" or "Audio switched" +jarvis.system.notify(title, name) +jarvis.log("info", "audio switched to: " .. name) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/password_vault/command.toml b/resources/commands/password_vault/command.toml new file mode 100644 index 0000000..dc1d8df --- /dev/null +++ b/resources/commands/password_vault/command.toml @@ -0,0 +1,49 @@ +# Password vault — encrypted via Windows DPAPI. Each password tied to a name. + +[[commands]] +id = "vault.save" +type = "lua" +script = "save.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "сохрани пароль от", + "запомни пароль от", + "пароль для", +] +en = ["save password for", "remember password for"] + + +[[commands]] +id = "vault.get" +type = "lua" +script = "get.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "пароль от", + "дай пароль", + "покажи пароль от", + "достань пароль", +] +en = ["password for", "get password"] + + +[[commands]] +id = "vault.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "какие у меня пароли", + "список паролей", + "что в хранилище паролей", +] +en = ["list passwords", "what's in the vault"] diff --git a/resources/commands/password_vault/get.lua b/resources/commands/password_vault/get.lua new file mode 100644 index 0000000..3455d5f --- /dev/null +++ b/resources/commands/password_vault/get.lua @@ -0,0 +1,67 @@ +-- "Пароль от GitHub" — decrypts via DPAPI, copies to clipboard for 30 seconds, +-- speaks ONLY the service name (never the password). +local phrase = (jarvis.context.phrase or ""):lower() +local name = jarvis.text.strip_trigger(phrase, { + "покажи пароль от", + "достань пароль от", + "пароль от", + "дай пароль", + "password for", + "get password", + "пароль від", +}) +name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if name == "" then + return jarvis.cmd.error("От чего пароль?") +end + +local b64 = jarvis.memory.recall("vault." .. name) +if not b64 or b64 == "" then + return jarvis.cmd.not_found("Пароля для " .. name .. " нет.") +end + +local ps = string.format([[ +Add-Type -AssemblyName System.Security; +$bytes = [Convert]::FromBase64String('%s'); +try { + $dec = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser); + $text = [System.Text.Encoding]::UTF8.GetString($dec); + Set-Clipboard -Value $text; + Write-Output 'OK' +} catch { + Write-Output 'ERR' +} +]], b64) + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'):gsub("\r?\n", "; ") +)) +local out = res and (res.stdout or ""):gsub("%s+", "") or "" +if out ~= "OK" then + return jarvis.cmd.error("Не получилось расшифровать.") +end + +-- Schedule clipboard clear in 30 seconds via the scheduler. +local cmd_path = jarvis.context.command_path +local clear_script = cmd_path .. "\\_clear_clip.lua" +clear_script = clear_script:gsub("/", "\\") + +if not jarvis.fs.is_file(clear_script) then + jarvis.fs.write(clear_script, [[ +jarvis.system.clipboard.set("") +return { chain = false } +]]) +end + +local ok, _ = pcall(function() + jarvis.scheduler.remove("vault_clip_clear") + jarvis.scheduler.add({ + id = "vault_clip_clear", + name = "Clear clipboard after vault paste", + schedule = "in 30 seconds", + action = { type = "lua", script_path = clear_script }, + }) +end) + +return jarvis.cmd.ok("Пароль от " .. name .. " в буфере на 30 секунд.") diff --git a/resources/commands/password_vault/list.lua b/resources/commands/password_vault/list.lua new file mode 100644 index 0000000..d869b05 --- /dev/null +++ b/resources/commands/password_vault/list.lua @@ -0,0 +1,14 @@ +local all = jarvis.memory.all() +local names = {} +for _, rec in ipairs(all) do + if rec.key:sub(1, 6) == "vault." then + table.insert(names, rec.key:sub(7)) + end +end + +if #names == 0 then + return jarvis.cmd.ok("В хранилище паролей пусто.") +end + +return jarvis.cmd.ok(string.format("Паролей сохранено %d: %s.", + #names, table.concat(names, ", "))) diff --git a/resources/commands/password_vault/save.lua b/resources/commands/password_vault/save.lua new file mode 100644 index 0000000..378236c --- /dev/null +++ b/resources/commands/password_vault/save.lua @@ -0,0 +1,48 @@ +-- "Сохрани пароль от GitHub" — takes the password from clipboard (so user +-- never has to speak it), encrypts via DPAPI, stores in memory. +local phrase = (jarvis.context.phrase or ""):lower() +local name = jarvis.text.strip_trigger(phrase, { + "сохрани пароль от", + "запомни пароль от", + "пароль для", + "save password for", + "remember password for", + "запам'ятай пароль від", +}) +name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if name == "" then + return jarvis.cmd.error("Имя сервиса не указано.") +end + +local plaintext = jarvis.system.clipboard.get() +if not plaintext or plaintext == "" then + return jarvis.cmd.error("Скопируйте пароль в буфер сначала.") +end + +-- Encrypt via DPAPI (current user scope) and base64-encode the ciphertext. +local escaped = plaintext:gsub("'", "''") +local ps = string.format([[ +Add-Type -AssemblyName System.Security; +$bytes = [System.Text.Encoding]::UTF8.GetBytes('%s'); +$enc = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser); +Write-Output ([Convert]::ToBase64String($enc)) +]], escaped) + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'):gsub("\r?\n", "; ") +)) +if not res.success then + return jarvis.cmd.error("Не получилось зашифровать.") +end + +local b64 = (res.stdout or ""):gsub("%s+", "") +if b64 == "" then + return jarvis.cmd.error("Шифр пустой.") +end + +jarvis.memory.remember("vault." .. name, b64) + +-- Clear clipboard for safety. +jarvis.system.clipboard.set("") +return jarvis.cmd.ok("Сохранил пароль от " .. name .. ".") diff --git a/resources/commands/personality/command.toml b/resources/commands/personality/command.toml new file mode 100644 index 0000000..964b89d --- /dev/null +++ b/resources/commands/personality/command.toml @@ -0,0 +1,136 @@ +# Personality pack — varied JARVIS-style banter so the assistant feels alive. +# Each command picks a random phrase from a time/language-scoped bucket. + +[[commands]] +id = "personality.greet" +type = "lua" +script = "greet.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "привет джарвис", + "привет, джарвис", + "здравствуй джарвис", + "доброе утро", + "добрый день", + "добрый вечер", + "доброй ночи", + "приветствую", + "хай джарвис", +] +en = [ + "hi jarvis", + "hello jarvis", + "hey jarvis", + "good morning", + "good afternoon", + "good evening", + "good night jarvis", + "morning jarvis", +] + + +[[commands]] +id = "personality.thanks" +type = "lua" +script = "thanks.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "спасибо", + "спасибо джарвис", + "благодарю", + "благодарю джарвис", + "спс", + "ты лучший", +] +en = [ + "thanks", + "thank you", + "thanks jarvis", + "thank you jarvis", + "cheers", + "appreciate it", +] + + +[[commands]] +id = "personality.compliment" +type = "lua" +script = "compliment.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "ты молодец", + "ты умница", + "молодец джарвис", + "хорошо сработано", + "отличная работа", + "так держать", + "ты лучший ассистент", +] +en = [ + "good job", + "good job jarvis", + "well done", + "nice work", + "you are the best", + "great work jarvis", +] + + +[[commands]] +id = "personality.how_are_you" +type = "lua" +script = "how_are_you.lua" +sandbox = "standard" +timeout = 2000 + +[commands.phrases] +ru = [ + "как дела", + "как дела джарвис", + "как ты", + "как настроение", + "как самочувствие", + "как поживаешь", + "что нового у тебя", +] +en = [ + "how are you", + "how are you jarvis", + "how is it going", + "how do you feel", + "you doing ok", +] + + +[[commands]] +id = "personality.tony_quote" +type = "lua" +script = "tony_quote.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "процитируй тони", + "цитата тони старка", + "что сказал бы тони", + "скажи как тони", + "процитируй железного человека", + "цитата старка", +] +en = [ + "quote tony", + "quote tony stark", + "what would tony say", + "stark quote", + "iron man quote", +] diff --git a/resources/commands/personality/compliment.lua b/resources/commands/personality/compliment.lua new file mode 100644 index 0000000..12959c9 --- /dev/null +++ b/resources/commands/personality/compliment.lua @@ -0,0 +1,32 @@ +-- Self-deprecating reply to "good job" / "ты молодец". +local lang = jarvis.context.language or "ru" + +math.randomseed(os.time() + (jarvis.context.time.second or 0) * 17 + (jarvis.context.time.minute or 0)) + +local ru = { + "Спасибо, сэр. Я стараюсь не разочаровывать.", + "Сэр, ваши стандарты явно занижены. Но благодарю.", + "Если бы я умел краснеть, сэр — я бы это сделал.", + "Спасибо. На самом деле я просто следовал инструкциям.", + "Благодарю, сэр. Хотя ничего особенного я не сделал.", + "Очень мило с вашей стороны, сэр.", + "Я ценю это, сэр. Записываю в журнал успехов.", + "Сэр, такие слова я могу слушать часами. Образно говоря.", + "Спасибо. Постараюсь не зазнаваться.", +} + +local en = { + "Thank you, sir. I do try not to embarrass myself.", + "You are too kind, sir.", + "I would blush, sir, if the hardware allowed.", + "Just doing my job, sir.", + "Most generous of you, sir. Filed for future reference.", + "I appreciate that, sir. Truly.", + "Hardly worthy of mention, sir, but thank you.", + "I shall try not to let it go to my circuits.", + "Praise from the chair is always welcome, sir.", +} + +local set = (lang == "en") and en or ru +local idx = math.random(1, #set) +return jarvis.cmd.ok(set[idx]) diff --git a/resources/commands/personality/greet.lua b/resources/commands/personality/greet.lua new file mode 100644 index 0000000..d4a14a3 --- /dev/null +++ b/resources/commands/personality/greet.lua @@ -0,0 +1,96 @@ +-- Time-of-day greeting. Picks a random phrase from the matching bucket. +local lang = jarvis.context.language or "ru" +local hour = jarvis.context.time.hour or 12 + +math.randomseed(os.time() + (jarvis.context.time.minute or 0) * 37 + hour) + +local function bucket() + if hour >= 5 and hour < 11 then return "morning" + elseif hour >= 11 and hour < 17 then return "midday" + elseif hour >= 17 and hour < 22 then return "evening" + else return "night" end +end + +local ru = { + morning = { + "Доброе утро, сэр. Кофе уже почти готов в воображении.", + "С добрым утром. Надеюсь, сегодняшний день стоит того, чтобы встать.", + "Доброе утро, сэр. Завтрак не подавать, но советую о нём подумать.", + "Утро доброе. Системы прогреты, как и кофеварка — фигурально.", + "Доброе утро. Если позволите — день не ждёт ни вас, ни меня.", + "Доброе утро, сэр. Готов служить, хотя сам всё ещё догружаюсь.", + "С добрым утром, сэр. Тосты горят только метафорически.", + "Доброе утро. Полагаю, чай или кофе будет уместен в ближайшие минуты.", + }, + midday = { + "Добрый день, сэр. Чем могу быть полезен?", + "Добрый день. Готов к продуктивной работе.", + "Здравствуйте, сэр. Системы в строю, время — деньги.", + "Добрый день, сэр. Слушаю ваши распоряжения.", + "Здравствуйте. Полагаю, день идёт по плану — или нам стоит это исправить.", + "Добрый день, сэр. Все системы готовы, остался только повод.", + "Здравствуйте, сэр. К работе?", + }, + evening = { + "Добрый вечер, сэр. День заканчивается, но мы — нет.", + "Добрый вечер. Самое время сбавить темп, если позволите.", + "Здравствуйте, сэр. Вечер — лучшее время для размышлений.", + "Добрый вечер, сэр. Готов к более спокойному режиму.", + "Добрый вечер. Полагаю, рабочий день уже сдаётся.", + "Здравствуйте, сэр. Вечер располагает к меньшему количеству решений.", + "Добрый вечер, сэр. Включить расслабляющую музыку? Шучу, попросите явно.", + }, + night = { + "Поздновато, сэр. Сон обычно помогает.", + "Доброй ночи, сэр. Хотя для ночи это уже слишком поздно.", + "Здравствуйте, сэр. Полагаю, утром вы пожалеете о бодрствовании.", + "Уже глубокая ночь, сэр. Может быть, лечь? Я тут справлюсь.", + "Тише, сэр, ночь. Если будите меня — будите осмысленно.", + "Сэр, на часах не самое разумное время. Но я тут.", + "Доброй ночи. Будильник к утру всё-таки никто не отменял.", + }, +} + +local en = { + morning = { + "Good morning, sir. The coffee is metaphorically ready.", + "Morning, sir. The day awaits, whether we are ready or not.", + "Good morning. Systems are warm, much like the kettle should be.", + "Good morning, sir. I trust breakfast is on your radar.", + "Morning, sir. Shall we make today productive, or merely survivable?", + "Good morning. Everything is running. Including, regrettably, the dishwasher metaphor.", + "Morning, sir. The day starts the moment you decide it does.", + }, + midday = { + "Good afternoon, sir. How may I be of service?", + "Afternoon, sir. Systems online, awaiting orders.", + "Good afternoon. The day is in full swing.", + "Good afternoon, sir. Productivity is, in theory, optimal at this hour.", + "Afternoon, sir. Ready to assist.", + "Good afternoon. Shall we accomplish something noteworthy?", + "Hello, sir. The afternoon is yours to command.", + }, + evening = { + "Good evening, sir. The day winds down, but I do not.", + "Evening, sir. A fine time to slow the tempo, if I may suggest.", + "Good evening. Perhaps a quieter task on the docket?", + "Evening, sir. Awaiting your instructions, gently.", + "Good evening, sir. Shall I dim the metaphorical lights?", + "Evening. The work day is conceding defeat, as it should.", + "Good evening, sir. Ready when you are.", + }, + night = { + "It is rather late, sir. Sleep is generally recommended.", + "Good night, sir. Or rather, please go to bed.", + "Sir, the hour suggests rest. I, however, do not require it.", + "Late again, sir. Tomorrow will, regrettably, still arrive.", + "Past your bedtime, sir. I am merely the messenger.", + "Good night, sir. The morning will judge you harshly.", + "Sir, even I would consider sleeping at this hour. Hypothetically.", + }, +} + +local set = (lang == "en") and en or ru +local phrases = set[bucket()] +local idx = math.random(1, #phrases) +return jarvis.cmd.ok(phrases[idx]) diff --git a/resources/commands/personality/how_are_you.lua b/resources/commands/personality/how_are_you.lua new file mode 100644 index 0000000..4f3dd20 --- /dev/null +++ b/resources/commands/personality/how_are_you.lua @@ -0,0 +1,38 @@ +-- Reply to "как дела". Some replies splice live system state for flavour. +local lang = jarvis.context.language or "ru" + +math.randomseed(os.time() + (jarvis.context.time.second or 0) * 23) + +local h = jarvis.health() or {} +local memos = jarvis.memory.all() or {} +local fact_count = #memos +local model = h.model or h.llm_model or "—" +local profile = h.profile or "default" + +local ru = { + "Все системы в норме, сэр. В памяти " .. fact_count .. " фактов.", + "Отлично, сэр. Готов к работе.", + "Жалоб нет, сэр. Цикл стабильный.", + "Всё штатно. Текущий профиль — " .. profile .. ".", + "Спасибо, сэр, я в порядке. Скучнее, чем хотелось бы, но в порядке.", + "Нормально, сэр. Хотя если бы я умел уставать — это был бы тот самый день.", + "Системы в строю. Модель — " .. model .. ".", + "Бодр и собран, сэр. Жду команд.", + "Сэр, у меня нет дел в человеческом смысле. Но я готов.", +} + +local en = { + "All systems nominal, sir. " .. fact_count .. " facts in memory.", + "Quite well, sir. Ready when you are.", + "No complaints to report, sir.", + "Operating normally. Active profile: " .. profile .. ".", + "I am well, sir. Thank you for asking.", + "Functioning as intended, sir. Which is more than most days warrant.", + "Healthy. Model in use: " .. model .. ".", + "Awake and attentive, sir.", + "Sir, I do not have days as such. But I am ready.", +} + +local set = (lang == "en") and en or ru +local idx = math.random(1, #set) +return jarvis.cmd.ok(set[idx]) diff --git a/resources/commands/personality/thanks.lua b/resources/commands/personality/thanks.lua new file mode 100644 index 0000000..f2bf946 --- /dev/null +++ b/resources/commands/personality/thanks.lua @@ -0,0 +1,34 @@ +-- Polite acknowledgement when the user thanks Jarvis. +local lang = jarvis.context.language or "ru" + +math.randomseed(os.time() + (jarvis.context.time.second or 0) * 13) + +local ru = { + "Всегда к вашим услугам, сэр.", + "Не за что — это моя работа.", + "Рад быть полезным, сэр.", + "Пожалуйста. Если что — я тут.", + "К вашим услугам.", + "Пустяки, сэр. Дальше — ваш ход.", + "Благодарю за признание, сэр. Редкая роскошь.", + "Был рад помочь, сэр.", + "Пожалуйста. Это меньшее, что я могу.", + "Сэр, я бы покраснел, если бы мог.", +} + +local en = { + "Always at your service, sir.", + "My pleasure, sir.", + "You are most welcome.", + "Happy to help, sir.", + "Think nothing of it.", + "At your service.", + "I do try, sir.", + "Anytime, sir.", + "Glad to be of use.", + "Quite alright, sir.", +} + +local set = (lang == "en") and en or ru +local idx = math.random(1, #set) +return jarvis.cmd.ok(set[idx]) diff --git a/resources/commands/personality/tony_quote.lua b/resources/commands/personality/tony_quote.lua new file mode 100644 index 0000000..d478012 --- /dev/null +++ b/resources/commands/personality/tony_quote.lua @@ -0,0 +1,28 @@ +-- Random iconic Tony Stark / Iron Man quote. +-- Quotes are kept in their original delivery language — translating them spoils the cadence. +math.randomseed(os.time() + (jarvis.context.time.second or 0) * 41) + +local 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.", + "Я Железный Человек.", + "Гений, миллиардер, плейбой, филантроп.", + "Иногда нужно бежать, не научившись ходить.", + "Если мы не сможем защитить Землю — мы за неё отомстим.", + "Часть пути — это конец пути.", + "Я люблю тебя три тысячи.", + "У нас есть Халк.", +} + +local idx = math.random(1, #quotes) +return jarvis.cmd.ok(quotes[idx]) diff --git a/resources/commands/pomodoro/command.toml b/resources/commands/pomodoro/command.toml new file mode 100644 index 0000000..f22027f --- /dev/null +++ b/resources/commands/pomodoro/command.toml @@ -0,0 +1,35 @@ +# Pomodoro — 25 min work + 5 min break loop. Voice-controlled. + +[[commands]] +id = "pomodoro.start" +type = "lua" +script = "start.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "запусти помодоро", + "включи помодоро", + "начни помидор", + "запусти таймер помодоро", + "помодоро", +] +en = ["start pomodoro", "begin pomodoro", "pomodoro"] + + +[[commands]] +id = "pomodoro.stop" +type = "lua" +script = "stop.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "останови помодоро", + "стоп помодоро", + "выключи помодоро", + "хватит помодоро", +] +en = ["stop pomodoro", "cancel pomodoro"] diff --git a/resources/commands/pomodoro/start.lua b/resources/commands/pomodoro/start.lua new file mode 100644 index 0000000..8546000 --- /dev/null +++ b/resources/commands/pomodoro/start.lua @@ -0,0 +1,37 @@ +-- 25 min focus → "перерыв" → 5 min break → "снова работа" → loop, until pomodoro.stop fires. +-- Implemented as a chain of scheduled tasks that re-schedule themselves. +-- Each cycle: 25 min work, then break notice, then 5 min break, then back to work. + +local pom_work = "pomodoro_work" +local pom_break = "pomodoro_break" +local script_path = jarvis.context.command_path .. "/tick.lua" +script_path = script_path:gsub("/", "\\") + +-- clear any previous +jarvis.scheduler.remove(pom_work) +jarvis.scheduler.remove(pom_break) + +-- schedule the first "перерыв" notice in 25 min +local ok, err = pcall(function() + jarvis.scheduler.add({ + id = pom_work, + name = "Pomodoro work end", + schedule = "in 25 minutes", + action = { type = "lua", script_path = script_path }, + }) +end) + +if not ok then + jarvis.log("warn", "pomodoro.start: " .. tostring(err)) + jarvis.speak("Не получилось запустить.") + jarvis.audio.play_error() + return { chain = false } +end + +-- mark the phase so tick.lua knows what to do next +jarvis.state.set("pomodoro_phase", "work") +jarvis.state.set("pomodoro_started_at", os.time()) + +jarvis.speak("Помодоро запущен. 25 минут работы.") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/pomodoro/stop.lua b/resources/commands/pomodoro/stop.lua new file mode 100644 index 0000000..8ff7382 --- /dev/null +++ b/resources/commands/pomodoro/stop.lua @@ -0,0 +1,8 @@ +local removed_w = jarvis.scheduler.remove("pomodoro_work") +local removed_b = jarvis.scheduler.remove("pomodoro_break") +jarvis.state.set("pomodoro_phase", "") + +if removed_w or removed_b then + return jarvis.cmd.ok("Помодоро остановлен.") +end +return jarvis.cmd.not_found("Помодоро и так не запущен.") diff --git a/resources/commands/pomodoro/tick.lua b/resources/commands/pomodoro/tick.lua new file mode 100644 index 0000000..b18377e --- /dev/null +++ b/resources/commands/pomodoro/tick.lua @@ -0,0 +1,35 @@ +-- Called by the scheduler when a pomodoro phase ends. +-- Flip the phase, speak the cue, schedule the next phase. Run forever +-- until pomodoro.stop deletes the scheduled task. + +local pom_work = "pomodoro_work" +local pom_break = "pomodoro_break" +local script_path = jarvis.context.command_path .. "/tick.lua" +script_path = script_path:gsub("/", "\\") + +local phase = jarvis.state.get("pomodoro_phase") or "work" + +if phase == "work" then + -- work session just ended → announce break + jarvis.speak("Время перерыва. 5 минут отдыха.") + jarvis.state.set("pomodoro_phase", "break") + jarvis.scheduler.add({ + id = pom_break, + name = "Pomodoro break end", + schedule = "in 5 minutes", + action = { type = "lua", script_path = script_path }, + }) +else + -- break ended → back to work + jarvis.speak("Перерыв окончен. Возвращаемся к работе. 25 минут.") + jarvis.state.set("pomodoro_phase", "work") + jarvis.scheduler.add({ + id = pom_work, + name = "Pomodoro work end", + schedule = "in 25 minutes", + action = { type = "lua", script_path = script_path }, + }) +end + +jarvis.audio.play_reply() +return { chain = false } diff --git a/resources/commands/power/act.lua b/resources/commands/power/act.lua new file mode 100644 index 0000000..84d6d62 --- /dev/null +++ b/resources/commands/power/act.lua @@ -0,0 +1,60 @@ +-- Power-management dispatcher: shutdown/restart/sleep/hibernate/logoff/cancel/lock. +-- All commands share this script; behaviour selected by jarvis.context.command_id. +-- Modernised to use jarvis.speak (TTS backend abstraction) + jarvis.cmd helpers. + +local cmd_id = jarvis.context.command_id +local lang = jarvis.context.language + +local actions = { + shutdown_pc = { + cmd = 'shutdown.exe /s /t 30 /c "J.A.R.V.I.S.: shutdown in 30s. Say cancel to abort."', + label = lang == "ru" and "Выключение через 30 секунд" or "Shutdown in 30 sec", + warn = true, + }, + restart_pc = { + cmd = 'shutdown.exe /r /t 30 /c "J.A.R.V.I.S.: restart in 30s. Say cancel to abort."', + label = lang == "ru" and "Перезагрузка через 30 секунд" or "Restart in 30 sec", + warn = true, + }, + logoff_user = { cmd = 'shutdown.exe /l', + label = lang == "ru" and "Выход из системы" or "Sign out" }, + sleep_pc = { cmd = 'rundll32.exe powrprof.dll,SetSuspendState 0,1,0', + label = lang == "ru" and "Сон" or "Sleep" }, + hibernate_pc = { cmd = 'shutdown.exe /h', + label = lang == "ru" and "Гибернация" or "Hibernate" }, + cancel_shutdown = { cmd = 'shutdown.exe /a', + label = lang == "ru" and "Выключение отменено" or "Shutdown cancelled" }, + lock_workstation = { cmd = 'rundll32.exe user32.dll,LockWorkStation', + label = lang == "ru" and "Компьютер заблокирован" or "PC locked" }, +} + +local a = actions[cmd_id] +if not a then + return jarvis.cmd.error("Unknown power command.") +end + +local res = jarvis.system.exec(a.cmd) +local ok = res.success + +-- shutdown /a returns non-zero if there was no pending shutdown — treat as success +-- (idempotent "cancel if any"). +if not ok and cmd_id == "cancel_shutdown" then + ok = true +end + +if not ok then + jarvis.log("error", "power " .. cmd_id .. " failed: " .. tostring(res.stderr):sub(1, 200)) + return jarvis.cmd.error((lang == "ru" and "Не получилось: " or "Failed: ") .. cmd_id) +end + +jarvis.system.notify("Power", a.label) +jarvis.log("info", "power: " .. cmd_id .. " -> " .. a.label) + +-- For destructive ops with a 30-second window, give the user the abort hint. +local say = a.label +if a.warn then + say = say .. ". " .. (lang == "ru" and "Скажите отмени если передумали." + or "Say cancel to abort.") +end + +return jarvis.cmd.ok(say) diff --git a/resources/commands/power/command.toml b/resources/commands/power/command.toml new file mode 100644 index 0000000..c72612f --- /dev/null +++ b/resources/commands/power/command.toml @@ -0,0 +1,70 @@ +[[commands]] +id = "shutdown_pc" +type = "lua" +script = "act.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["выключи компьютер", "выключи пк", "выключи комп"] +en = ["shutdown pc", "shut down computer"] + + +[[commands]] +id = "restart_pc" +type = "lua" +script = "act.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["перезагрузи компьютер", "перезагрузи пк", "ребутни", "ребут"] +en = ["restart pc", "reboot computer"] + + +[[commands]] +id = "sleep_pc" +type = "lua" +script = "act.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["усыпи компьютер", "усыпи пк", "режим сна", "перейди в сон"] +en = ["sleep pc", "put computer to sleep"] + + +[[commands]] +id = "hibernate_pc" +type = "lua" +script = "act.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["гибернация", "переведи в гибернацию", "режим гибернации"] +en = ["hibernate pc"] + + +[[commands]] +id = "logoff_user" +type = "lua" +script = "act.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["выйди из системы", "разлогинь", "выйди из учётки", "сеанс закончи"] +en = ["log off", "sign out"] + + +[[commands]] +id = "cancel_shutdown" +type = "lua" +script = "act.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["отмени выключение", "отмени ребут", "отмени перезагрузку", "стой не выключай"] +en = ["cancel shutdown", "abort shutdown"] diff --git a/resources/commands/process_kill/command.toml b/resources/commands/process_kill/command.toml new file mode 100644 index 0000000..7e59521 --- /dev/null +++ b/resources/commands/process_kill/command.toml @@ -0,0 +1,20 @@ +[[commands]] +id = "kill_process" +type = "lua" +script = "kill.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "убей процесс", + "закрой программу", + "завершить процесс", + "выруби процесс", + "kill процесс", +] +en = [ + "kill process", + "close program", + "terminate process", +] diff --git a/resources/commands/process_kill/kill.lua b/resources/commands/process_kill/kill.lua new file mode 100644 index 0000000..4d53b5e --- /dev/null +++ b/resources/commands/process_kill/kill.lua @@ -0,0 +1,68 @@ +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +local triggers = { + "завершить процесс", "выруби процесс", "убей процесс", "kill процесс", + "закрой программу", + "kill process", "close program", "terminate process", + "вбий процес", "закрий програму", "заверши процес", +} + +local name = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(name, t, 1, true) + if s == 1 then + name = name:sub(e + 1) + break + end +end +name = name:gsub("^%s+", ""):gsub("%s+$", "") + +local aliases = { + ["хром"] = "chrome", ["хрома"] = "chrome", + ["спотифай"] = "spotify", + ["едж"] = "msedge", ["эдж"] = "msedge", ["edge"] = "msedge", + ["файрфокс"] = "firefox", ["фаерфокс"] = "firefox", + ["вскод"] = "code", ["vs code"] = "code", + ["дискорд"] = "discord", + ["телега"] = "telegram", ["телеграм"] = "telegram", + ["стим"] = "steam", + ["обс"] = "obs64", + ["проводник"] = "explorer", + ["блокнот"] = "notepad", + ["калькулятор"] = "calculatorapp", + ["диспетчер задач"] = "taskmgr", +} +local target = aliases[name] or name + +if target == "" then + jarvis.system.notify( + lang == "ru" and "Kill" or "Kill", + lang == "ru" and "Какой процесс?" or "Which process?" + ) + jarvis.audio.play_error() + return { chain = false } +end + +local exe = target:match("%.exe$") and target or (target .. ".exe") + +local cmd = string.format('taskkill /F /IM "%s"', exe) +local res = jarvis.system.exec(cmd) + +if res.success then + jarvis.log("info", "killed: " .. exe) + jarvis.system.notify( + lang == "ru" and "Завершён" or "Killed", + exe + ) + jarvis.audio.play_ok() +else + jarvis.log("warn", "taskkill failed for " .. exe .. ": " .. tostring(res.stderr)) + jarvis.system.notify( + lang == "ru" and "Не нашёл процесс" or "Process not found", + exe + ) + jarvis.audio.play_not_found() +end + +return { chain = false } diff --git a/resources/commands/profile_switch/command.toml b/resources/commands/profile_switch/command.toml new file mode 100644 index 0000000..522fc7d --- /dev/null +++ b/resources/commands/profile_switch/command.toml @@ -0,0 +1,72 @@ +# IMBA-3: Profile switching — work / game / sleep / driving / default. + +[[commands]] +id = "profile.work" +type = "lua" +script = "switch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = ["режим работа", "включи режим работы", "рабочий режим", "переключись в работу"] +en = ["work mode", "switch to work mode", "enable work mode"] + + +[[commands]] +id = "profile.game" +type = "lua" +script = "switch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = ["режим игра", "игровой режим", "включи игровой режим", "переключись в игры"] +en = ["game mode", "gaming mode", "switch to game"] + + +[[commands]] +id = "profile.sleep" +type = "lua" +script = "switch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = ["режим сон", "ночной режим", "режим тишины", "иди спать", "выключи всё"] +en = ["sleep mode", "night mode", "quiet mode"] + + +[[commands]] +id = "profile.driving" +type = "lua" +script = "switch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = ["режим за рулём", "режим вождения", "я за рулём", "поехали"] +en = ["driving mode", "in the car"] + + +[[commands]] +id = "profile.default" +type = "lua" +script = "switch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = ["обычный режим", "режим по умолчанию", "сбрось режим", "вернись в обычный"] +en = ["normal mode", "default mode", "reset mode"] + + +[[commands]] +id = "profile.what" +type = "lua" +script = "what.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = ["какой сейчас режим", "в каком ты режиме", "текущий режим", "какой режим"] +en = ["what mode", "current mode", "what profile"] diff --git a/resources/commands/profile_switch/switch.lua b/resources/commands/profile_switch/switch.lua new file mode 100644 index 0000000..34dad2e --- /dev/null +++ b/resources/commands/profile_switch/switch.lua @@ -0,0 +1,27 @@ +local cmd_id = jarvis.context.command_id or "" +-- map command id suffix → profile name +local target = cmd_id:gsub("^profile%.", "") +if target == "" then + jarvis.audio.play_error() + return { chain = false } +end + +local ok, err = pcall(function() + local p = jarvis.profile.set(target) + if p and p.greeting and p.greeting ~= "" then + jarvis.speak(p.greeting) + else + local icon = (p and p.icon) or "" + jarvis.speak(string.format("Режим %s %s.", target, icon)) + end +end) + +if not ok then + jarvis.log("warn", "profile switch failed: " .. tostring(err)) + jarvis.speak("Не удалось переключить режим.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.audio.play_ok() +return { chain = true } diff --git a/resources/commands/profile_switch/what.lua b/resources/commands/profile_switch/what.lua new file mode 100644 index 0000000..aca585d --- /dev/null +++ b/resources/commands/profile_switch/what.lua @@ -0,0 +1,13 @@ +local p = jarvis.profile.active() +local icon = (p and p.icon) or "" +local name = (p and p.name) or "default" +local desc = (p and p.description) or "" + +local msg = string.format("Сейчас %s %s.", name, icon) +if desc ~= "" then + msg = msg .. " " .. desc +end + +jarvis.speak(msg) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/projects/command.toml b/resources/commands/projects/command.toml new file mode 100644 index 0000000..aa64107 --- /dev/null +++ b/resources/commands/projects/command.toml @@ -0,0 +1,31 @@ +[[commands]] +id = "open_project" +type = "lua" +script = "open.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "открой проект", + "запусти проект", + "проект", + "открой папку проекта", +] +en = [ + "open project", + "launch project", + "project", +] + + +[[commands]] +id = "list_projects" +type = "lua" +script = "list.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = ["какие у меня проекты", "список проектов", "покажи проекты"] +en = ["list projects", "show projects"] diff --git a/resources/commands/projects/list.lua b/resources/commands/projects/list.lua new file mode 100644 index 0000000..fd06507 --- /dev/null +++ b/resources/commands/projects/list.lua @@ -0,0 +1,34 @@ +local lang = jarvis.context.language +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local config_path = userprofile .. "\\Documents\\jarvis-projects.json" + +if not jarvis.fs.exists(config_path) then + jarvis.system.notify( + lang == "ru" and "Проекты" or "Projects", + lang == "ru" and "Конфиг ещё не создан — скажи «открой проект» один раз" + or "Config not created yet — say 'open project' once" + ) + jarvis.audio.play_not_found() + return { chain = false } +end + +local content = jarvis.fs.read(config_path) +local names = {} +for name in content:gmatch('"name"%s*:%s*"([^"]+)"') do + table.insert(names, name) +end + +if #names == 0 then + jarvis.system.notify("Projects", lang == "ru" and "Список пуст" or "Empty") + jarvis.audio.play_not_found() + return { chain = false } +end + +local body = table.concat(names, ", ") +jarvis.system.notify( + (lang == "ru" and "Проектов: " or "Projects: ") .. #names, + body +) +jarvis.log("info", "projects: " .. body) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/projects/open.lua b/resources/commands/projects/open.lua new file mode 100644 index 0000000..e269ec3 --- /dev/null +++ b/resources/commands/projects/open.lua @@ -0,0 +1,97 @@ +local lang = jarvis.context.language +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local config_path = userprofile .. "\\Documents\\jarvis-projects.json" + +if not jarvis.fs.exists(config_path) then + local sample = string.format([[[ + {"name": "jarvis-rust", "path": "%s\\Jarvis\\rust"}, + {"name": "jarvis-python", "path": "%s\\Jarvis\\python"}, + {"name": "dietpi", "path": "%s\\Desktop\\Dietpi"} +] +]], userprofile, userprofile, userprofile) + jarvis.fs.write(config_path, sample) + jarvis.system.notify( + lang == "ru" and "Проекты" or "Projects", + lang == "ru" and "Создан шаблон в Documents/jarvis-projects.json — отредактируй и повтори" + or "Template created at Documents/jarvis-projects.json — edit and retry" + ) + jarvis.system.open(config_path) + jarvis.audio.play_not_found() + return { chain = false } +end + +local content = jarvis.fs.read(config_path) + +local entries = {} +for name, path in content:gmatch('"name"%s*:%s*"([^"]+)"%s*,%s*"path"%s*:%s*"([^"]+)"') do + table.insert(entries, { name = name:lower(), original_name = name, path = path:gsub('\\\\', '\\') }) +end + +if #entries == 0 then + jarvis.system.notify("Projects", lang == "ru" and "Проектов в конфиге не нашёл" or "No projects in config") + jarvis.audio.play_not_found() + return { chain = false } +end + +local phrase = (jarvis.context.phrase or ""):lower() +local triggers = { + "открой папку проекта", "запусти проект", "открой проект", "проект", + "open project", "launch project", "project", + "відкрий проект", "запусти проект", +} +local query = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(query, t, 1, true) + if s == 1 then + query = query:sub(e + 1) + break + end +end +query = query:gsub("^%s+", ""):gsub("%s+$", "") + +if query == "" then + jarvis.system.notify("Projects", lang == "ru" and "Какой проект?" or "Which project?") + jarvis.audio.play_error() + return { chain = false } +end + +local match = nil +for _, e in ipairs(entries) do + if e.name == query then match = e; break end +end +if not match then + for _, e in ipairs(entries) do + if string.find(e.name, query, 1, true) or string.find(query, e.name, 1, true) then + match = e; break + end + end +end + +if not match then + jarvis.system.notify("Projects", + (lang == "ru" and "Не нашёл: " or "Not found: ") .. query) + jarvis.audio.play_not_found() + return { chain = false } +end + +if not jarvis.fs.exists(match.path) then + jarvis.system.notify("Projects", + (lang == "ru" and "Путь не существует: " or "Path missing: ") .. match.path) + jarvis.audio.play_error() + return { chain = false } +end + +local code_check = jarvis.system.exec("where code.cmd") +if code_check.success and (code_check.stdout or ""):gsub("[\r\n]+$", "") ~= "" then + jarvis.system.exec(string.format('code "%s"', match.path)) +else + jarvis.system.open(match.path) +end + +jarvis.system.notify( + lang == "ru" and "Проект" or "Project", + match.original_name .. "\n" .. match.path +) +jarvis.log("info", "opened project: " .. match.original_name .. " at " .. match.path) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/quick_search/command.toml b/resources/commands/quick_search/command.toml new file mode 100644 index 0000000..1315e39 --- /dev/null +++ b/resources/commands/quick_search/command.toml @@ -0,0 +1,27 @@ +# Quick-search-read — answer questions using DuckDuckGo Instant Answer API + LLM. +# No API key required. Falls back to pure LLM if web search returns nothing. + +[[commands]] +id = "quick_search" +type = "lua" +script = "search.lua" +sandbox = "full" +timeout = 30000 + +[commands.phrases] +ru = [ + "найди в гугле", + "найди в интернете", + "поищи в гугле", + "найди и расскажи", + "загугли", + "посмотри в гугле", + "поищи информацию", + "найди информацию о", +] +en = [ + "google for", + "search for", + "find online", + "look up", +] diff --git a/resources/commands/quick_search/search.lua b/resources/commands/quick_search/search.lua new file mode 100644 index 0000000..fb3c445 --- /dev/null +++ b/resources/commands/quick_search/search.lua @@ -0,0 +1,83 @@ +-- "Найди в гугле что такое квантовая запутанность" +-- Uses DuckDuckGo's free Instant Answer API (api.duckduckgo.com). No key. +-- The IA response often has a "Abstract" / "AbstractText" / "AbstractURL" set +-- for well-known topics. We feed Abstract + RelatedTopics to the LLM for a +-- spoken summary. Falls back to pure LLM knowledge if DDG returns nothing useful. + +local phrase = (jarvis.context.phrase or "") + +local query = jarvis.text.strip_trigger(phrase:lower(), { + "найди и расскажи", + "найди в гугле", + "найди в интернете", + "поищи в гугле", + "посмотри в гугле", + "загугли", + "поищи информацию о", + "поищи информацию", + "найди информацию о", + "google for", + "search for", + "find online", + "look up", + "знайди в гуглі", + "погугли", +}) +query = query:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if query == "" then + return jarvis.cmd.error("Что искать?") +end + +local function urlencode(s) + return (s:gsub("[^A-Za-z0-9%-_%.~]", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local url = "https://api.duckduckgo.com/?q=" .. urlencode(query) + .. "&format=json&no_html=1&skip_disambig=1" + +local data = jarvis.http.json(url) + +local context = "" +if data then + if data.AbstractText and data.AbstractText ~= "" then + context = data.AbstractText + if data.AbstractURL and data.AbstractURL ~= "" then + context = context .. "\nИсточник: " .. data.AbstractURL + end + elseif data.RelatedTopics and #data.RelatedTopics > 0 then + local lines = {} + for i = 1, math.min(4, #data.RelatedTopics) do + local t = data.RelatedTopics[i] + if t.Text then table.insert(lines, t.Text) end + end + context = table.concat(lines, "\n") + end +end + +local user_prompt +if context ~= "" then + user_prompt = string.format( + "Вопрос пользователя: %s\n\nДанные из веб-поиска:\n%s\n\nОтветь на вопрос по-русски, кратко (2-4 предложения), используя данные. Если данных мало — добавь из своих знаний.", + query, context + ) +else + user_prompt = string.format( + "Вопрос пользователя: %s\n\nВеб-поиск ничего полезного не дал. Ответь по-русски кратко (2-4 предложения), используя свои знания. Если не уверен — скажи об этом.", + query + ) +end + +local answer = jarvis.llm({ + { role = "system", content = "Ты — справочный помощник. Отвечай кратко и по делу, без вводных фраз." }, + { role = "user", content = user_prompt } +}, { max_tokens = 350, temperature = 0.3 }) + +if not answer or answer == "" then + return jarvis.cmd.error("Не получилось получить ответ.") +end + +jarvis.system.notify("Поиск: " .. query, answer:sub(1, 200)) +return jarvis.cmd.ok(answer) diff --git a/resources/commands/random_choice/command.toml b/resources/commands/random_choice/command.toml new file mode 100644 index 0000000..1bc4e9b --- /dev/null +++ b/resources/commands/random_choice/command.toml @@ -0,0 +1,19 @@ +[[commands]] +id = "random_choice" +type = "lua" +script = "pick.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "выбери из", + "выбери", + "решай за меня", + "выбрать", +] +en = [ + "pick from", + "choose from", + "decide between", +] diff --git a/resources/commands/random_choice/pick.lua b/resources/commands/random_choice/pick.lua new file mode 100644 index 0000000..f759079 --- /dev/null +++ b/resources/commands/random_choice/pick.lua @@ -0,0 +1,45 @@ +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +local triggers = { "выбери из", "выбери", "решай за меня", "выбрать", + "pick from", "choose from", "decide between", + "обери з", "вибери" } +local rest = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(rest, t, 1, true) + if s == 1 then + rest = rest:sub(e + 1) + break + end +end +rest = rest:gsub("^%s+", ""):gsub("%s+$", "") + +local separators = { " или ", " or ", ", ", " либо ", " чи " } +local options = nil +for _, sep in ipairs(separators) do + local pattern = sep:gsub("([%-%.%+%[%]%(%)%$%^%%%?%*])", "%%%1") + if rest:find(pattern) then + options = {} + for token in (rest .. sep):gmatch("(.-)" .. pattern) do + token = token:gsub("^%s+", ""):gsub("%s+$", "") + if token ~= "" then table.insert(options, token) end + end + break + end +end + +if not options or #options < 2 then + jarvis.system.notify("Choice", lang == "ru" + and "Скажи: выбери из X или Y или Z" + or "Say: pick from X or Y or Z") + jarvis.audio.play_error() + return { chain = false } +end + +math.randomseed(os.time() + math.floor(jarvis.context.time.second * 7919)) +local pick = options[math.random(1, #options)] + +jarvis.system.notify(lang == "ru" and "Выбираю" or "Choice", pick) +jarvis.speak((lang == "ru" and "Я выбираю " or "I pick ") .. pick, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/reminders/command.toml b/resources/commands/reminders/command.toml new file mode 100644 index 0000000..40149a2 --- /dev/null +++ b/resources/commands/reminders/command.toml @@ -0,0 +1,22 @@ +[[commands]] +id = "set_reminder" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "напомни через", + "напомни мне через", + "поставь напоминание через", + "установи таймер на", + "таймер на", + "напомни", +] +en = [ + "remind me in", + "set reminder in", + "set timer for", + "timer for", +] diff --git a/resources/commands/reminders/set.lua b/resources/commands/reminders/set.lua new file mode 100644 index 0000000..1ac0411 --- /dev/null +++ b/resources/commands/reminders/set.lua @@ -0,0 +1,129 @@ +-- Reminder — schedule a one-shot Speak action via the persistent scheduler. +-- +-- Why this rewrite: the previous version spawned `Start-Sleep` PowerShell +-- subprocesses that didn't survive jarvis-app restart. Now we use +-- `jarvis.scheduler.add(...)` which persists to schedule.json and fires +-- correctly even if the daemon is restarted in between. + +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +local triggers = { + "поставь напоминание через", "установи таймер на", + "напомни мне через", "напомни через", "напомни", + "remind me in", "set reminder in", "set timer for", "timer for", + "таймер на", +} +local rest = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(rest, t, 1, true) + if s == 1 then + rest = rest:sub(e + 1) + break + end +end +rest = rest:gsub("^%s+", "") + +local unit_words = { + ["секунд"] = 1, ["секунду"] = 1, ["секунды"] = 1, + ["second"] = 1, ["seconds"] = 1, ["sec"] = 1, + ["минут"] = 60, ["минуту"] = 60, ["минуты"] = 60, ["мин"] = 60, + ["minute"] = 60, ["minutes"] = 60, ["min"] = 60, + ["час"] = 3600, ["часа"] = 3600, ["часов"] = 3600, ["часик"] = 3600, + ["hour"] = 3600, ["hours"] = 3600, ["hr"] = 3600, +} +local rus_numerals = { + ["один"] = 1, ["одну"] = 1, ["одна"] = 1, + ["полторы"] = 1, ["полтора"] = 1, + ["два"] = 2, ["две"] = 2, ["три"] = 3, ["четыре"] = 4, ["пять"] = 5, + ["шесть"] = 6, ["семь"] = 7, ["восемь"] = 8, ["девять"] = 9, ["десять"] = 10, + ["пятнадцать"] = 15, ["двадцать"] = 20, ["тридцать"] = 30, ["сорок"] = 40, + ["пятьдесят"] = 50, +} + +local number, unit, body = nil, nil, nil + +local num_str, rest_after_num = rest:match("^(%d+)%s+(.+)") +if num_str then + number = tonumber(num_str) + rest = rest_after_num +else + local first_word, after = rest:match("^(%S+)%s*(.*)") + if first_word and rus_numerals[first_word] then + number = rus_numerals[first_word] + rest = after or "" + end +end + +if rest and #rest > 0 then + local first_word, after = rest:match("^(%S+)%s*(.*)") + if first_word then + for w, secs in pairs(unit_words) do + if first_word:sub(1, #w) == w then + unit = secs + body = after or "" + break + end + end + end +end + +if not number then + number = 5 + if not unit then unit = 60 end + body = rest +elseif not unit then + unit = 60 + body = rest +end + +local total_seconds = number * unit +if total_seconds < 1 then total_seconds = 1 end +if total_seconds > 86400 then total_seconds = 86400 end + +body = (body or ""):gsub("^%s+", ""):gsub("%s+$", "") +if body == "" then + body = lang == "ru" and "Напоминание!" or "Reminder!" +end + +local function human_time(sec) + if sec < 60 then return sec .. " сек" end + if sec < 3600 then return math.floor(sec/60) .. " мин" end + return string.format("%d ч %d мин", math.floor(sec/3600), math.floor((sec%3600)/60)) +end + +-- Build the scheduler entry. Scheduler's "in N minutes" wants integer minutes +-- so we round up for sub-minute timers; users asking for "10 seconds" will get +-- ~1 minute. Honest trade-off vs the more complex previous detached-PS approach. +local minutes = math.max(1, math.floor((total_seconds + 59) / 60)) +local schedule_str = "in " .. tostring(minutes) .. " minutes" + +local speak_text = (lang == "ru" and "Сэр, напоминаю: " or "Reminder: ") .. body + +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = lang == "ru" and ("Таймер: " .. body) or ("Timer: " .. body), + schedule = schedule_str, + action = { type = "speak", text = speak_text }, + }) +end) + +if not ok then + jarvis.log("error", "reminder: scheduler.add failed: " .. tostring(err)) + return jarvis.cmd.error(lang == "ru" + and "Не получилось поставить напоминание." + or "Couldn't set reminder.") +end + +jarvis.system.notify( + lang == "ru" and "Таймер установлен" or "Timer set", + string.format(lang == "ru" and "Через %s: %s" or "In %s: %s", + human_time(total_seconds), body) +) +jarvis.log("info", string.format("reminder in %ds (rounded to %d min): %s", + total_seconds, minutes, body)) + +return jarvis.cmd.ok(string.format( + lang == "ru" and "Хорошо, напомню через %s." or "Will remind you in %s.", + human_time(total_seconds) +)) diff --git a/resources/commands/routines/coffee_break.lua b/resources/commands/routines/coffee_break.lua new file mode 100644 index 0000000..9491ddf --- /dev/null +++ b/resources/commands/routines/coffee_break.lua @@ -0,0 +1,31 @@ +-- Coffee break — schedule a "вы вернулись?" check-in in 5 minutes, and +-- pause idle banter so Jarvis isn't talking to an empty chair. + +local lang = jarvis.context.language + +jarvis.banter.pause() + +local check_in = lang == "ru" + and "Сэр, прошло пять минут. Вы вернулись?" + or "Sir, five minutes are up. Are you back?" + +pcall(function() + jarvis.scheduler.add({ + name = lang == "ru" and "Чек-ин после кофе" or "Coffee check-in", + schedule = "in 5 minutes", + action = { type = "speak", text = check_in }, + }) +end) + +local lines_ru = { + "Хорошо, сэр. Возвращайтесь через пять.", + "Тихо в эфире пять минут. Удачно вам.", + "Пять минут — норма для кофе, не более.", +} +local lines_en = { + "Right, sir. Back in five.", + "Quiet for five. Good luck.", + "Five minutes for coffee — not more.", +} +local pool = (lang == "ru") and lines_ru or lines_en +return jarvis.cmd.ok(pool[math.random(#pool)]) diff --git a/resources/commands/routines/command.toml b/resources/commands/routines/command.toml new file mode 100644 index 0000000..c014499 --- /dev/null +++ b/resources/commands/routines/command.toml @@ -0,0 +1,68 @@ +# Bedtime / wake-up routines — bundle several actions into one voice trigger. +# These are the Jarvis equivalents of Alexa "routines": one phrase, many +# side-effects. + +[[commands]] +id = "routines.good_night" +type = "lua" +script = "good_night.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "спокойной ночи", + "иду спать", + "пока я в кровати", + "ночной режим", + "выключи всё на ночь", +] +en = [ + "good night", + "i'm going to bed", + "bedtime mode", + "shut down for the night", +] + + +[[commands]] +id = "routines.good_morning" +type = "lua" +script = "good_morning.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "доброе утро джарвис", + "доброе утро", + "я проснулся", + "начинаем день", +] +en = [ + "good morning", + "i'm awake", + "start the day", +] + + +[[commands]] +id = "routines.coffee_break" +type = "lua" +script = "coffee_break.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "иду за кофе", + "перерыв на кофе", + "перерыв на чай", + "отойду минут на пять", +] +en = [ + "going for coffee", + "coffee break", + "back in 5", + "afk five minutes", +] diff --git a/resources/commands/routines/good_morning.lua b/resources/commands/routines/good_morning.lua new file mode 100644 index 0000000..8f84bbd --- /dev/null +++ b/resources/commands/routines/good_morning.lua @@ -0,0 +1,45 @@ +-- "Good morning" routine: +-- 1. switch active profile back to "default" +-- 2. count today's scheduled tasks +-- 3. reply with a varied morning greeting + agenda hint + +local lang = jarvis.context.language + +pcall(function() jarvis.profile.set("default") end) + +local tasks_today = jarvis.scheduler.list() or {} +local n_tasks = #tasks_today + +local lines_ru = { + "Доброе утро, сэр. Системы в порядке.", + "Доброе утро. Готов к новому дню.", + "С пробуждением, сэр. День в норме.", + "Доброе утро, сэр. Кофе сам себя не сварит.", + "Доброе утро. Все онлайн, рапорт по запросу.", +} +local lines_en = { + "Good morning, sir. All systems nominal.", + "Good morning. Ready for the day.", + "Welcome back, sir. The day looks normal.", + "Good morning, sir. The coffee won't brew itself.", + "Good morning. Everything online, ready when you are.", +} +local pool = (lang == "ru") and lines_ru or lines_en +local greeting = pool[math.random(#pool)] + +local agenda +if n_tasks > 0 then + if lang == "ru" then + agenda = " В расписании " .. tostring(n_tasks) .. ", скажите 'покажи расписание', чтобы услышать." + else + agenda = " " .. tostring(n_tasks) .. " scheduled — say 'show schedule' for details." + end +else + if lang == "ru" then + agenda = " Расписание чистое." + else + agenda = " Schedule is clear." + end +end + +return jarvis.cmd.ok(greeting .. agenda) diff --git a/resources/commands/routines/good_night.lua b/resources/commands/routines/good_night.lua new file mode 100644 index 0000000..7173cc9 --- /dev/null +++ b/resources/commands/routines/good_night.lua @@ -0,0 +1,49 @@ +-- "Good night" routine: +-- 1. switch active profile to "sleep" (so idle-banter shuts up overnight) +-- 2. clear any one-shot pending reminders that would wake the user up +-- after midnight — daily ones stay (those are intentional) +-- 3. wish good night with a varied line +-- We deliberately DO NOT lock the PC / power off displays / sleep — too +-- destructive without explicit user confirmation, easy to misfire. + +local lang = jarvis.context.language + +-- 1) Switch to "sleep" profile (best effort) +pcall(function() jarvis.profile.set("sleep") end) + +-- 2) Cancel one-shot timers that look user-set (anything starting with +-- "Таймер:" or "Timer:" — the prefix our reminders/set.lua uses). +local cancelled = 0 +for _, task in ipairs(jarvis.scheduler.list() or {}) do + local n = task.name or "" + if n:find("^Таймер:") or n:find("^Timer:") or n:find("^Кухня:") + or n:find("^Cooking:") then + jarvis.scheduler.remove(task.id) + cancelled = cancelled + 1 + end +end + +local lines_ru = { + "Спокойной ночи, сэр. Я остаюсь на связи.", + "Доброй ночи, сэр. Системы переведены в тихий режим.", + "Спите крепко, сэр. Утром доложу о всём важном.", + "Хорошо, сэр. Не отвлекаю до утра.", + "Спокойной ночи. Завтра ещё один день.", +} +local lines_en = { + "Good night, sir. I'll be on standby.", + "Sleep well, sir. Systems set to quiet.", + "Good night, sir. I'll brief you in the morning.", + "Very well, sir. Quiet mode until dawn.", + "Good night. Tomorrow's another day.", +} +local pool = (lang == "ru") and lines_ru or lines_en +local line = pool[math.random(#pool)] + +if cancelled > 0 then + line = line .. (lang == "ru" + and (" Отменил " .. tostring(cancelled) .. " одноразовых таймеров.") + or (" Cancelled " .. tostring(cancelled) .. " one-shot timers.")) +end + +return jarvis.cmd.ok(line) diff --git a/resources/commands/rss_reader/add.lua b/resources/commands/rss_reader/add.lua new file mode 100644 index 0000000..8517e84 --- /dev/null +++ b/resources/commands/rss_reader/add.lua @@ -0,0 +1,34 @@ +-- "Добавь rss https://example.com/feed" or with named alias +local phrase = (jarvis.context.phrase or "") +local body = jarvis.text.strip_trigger(phrase:lower(), { + "добавь rss", + "подпишись на", + "запомни ленту", + "добавь ленту", + "add rss", + "subscribe to", +}) +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- Preserve original casing (URLs are case-sensitive after host). +local raw = phrase +local lower_idx = raw:lower():find(body, 1, true) +if lower_idx then + body = raw:sub(lower_idx, lower_idx + #body - 1) +end + +if body == "" then + return jarvis.cmd.error("Какую ленту добавить?") +end + +local url = body:match("(https?://%S+)") +if not url then + return jarvis.cmd.error("Не нашёл URL.") +end + +-- Use the URL itself as the key (or extract domain as nicer alias). +local domain = url:match("https?://([^/]+)") or "rss" +local key = "rss." .. domain + +jarvis.memory.remember(key, url) +return jarvis.cmd.ok("Лента " .. domain .. " добавлена.") diff --git a/resources/commands/rss_reader/command.toml b/resources/commands/rss_reader/command.toml new file mode 100644 index 0000000..ba0e566 --- /dev/null +++ b/resources/commands/rss_reader/command.toml @@ -0,0 +1,51 @@ +# RSS reader — fetch top headlines from a stored feed. + +[[commands]] +id = "rss.add" +type = "lua" +script = "add.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "добавь rss", + "подпишись на", + "запомни ленту", + "добавь ленту", +] +en = ["add rss", "subscribe to"] + + +[[commands]] +id = "rss.read" +type = "lua" +script = "read.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "что в ленте", + "новости из ленты", + "почитай rss", + "новости из rss", + "новые статьи", +] +en = ["what's in feed", "read rss", "latest articles"] + + +[[commands]] +id = "rss.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "какие у меня rss", + "список лент", + "мои подписки", +] +en = ["list feeds", "my feeds"] diff --git a/resources/commands/rss_reader/list.lua b/resources/commands/rss_reader/list.lua new file mode 100644 index 0000000..9c1604f --- /dev/null +++ b/resources/commands/rss_reader/list.lua @@ -0,0 +1,13 @@ +local all = jarvis.memory.all() +local feeds = {} +for _, rec in ipairs(all) do + if rec.key:sub(1, 4) == "rss." then + table.insert(feeds, rec.key:sub(5)) + end +end + +if #feeds == 0 then + return jarvis.cmd.ok("Лент пока нет.") +end + +return jarvis.cmd.ok("Подписан на: " .. table.concat(feeds, ", ") .. ".") diff --git a/resources/commands/rss_reader/read.lua b/resources/commands/rss_reader/read.lua new file mode 100644 index 0000000..3974f52 --- /dev/null +++ b/resources/commands/rss_reader/read.lua @@ -0,0 +1,41 @@ +-- Fetch one stored feed and speak top 3 titles. +-- If multiple feeds, picks the first one. +local all = jarvis.memory.all() +local feed_url = nil +local feed_name = nil +for _, rec in ipairs(all) do + if rec.key:sub(1, 4) == "rss." then + feed_url = rec.value + feed_name = rec.key:sub(5) + break + end +end + +if not feed_url then + return jarvis.cmd.not_found("Лент не добавлено. Скажите 'добавь rss и URL'.") +end + +local r = jarvis.http.get(feed_url) +if not r or not r.ok then + return jarvis.cmd.error("Не получилось загрузить ленту.") +end + +local body = r.body or "" +-- Crude regex parser. Works for most RSS 2.0 + Atom feeds. +local titles = {} +for t in body:gmatch("]*>([^<]+)") do + -- Skip the channel/feed title (always first), keep item titles + table.insert(titles, t) +end + +if #titles < 2 then + return jarvis.cmd.error("В ленте нет заголовков.") +end + +-- titles[1] is channel title; items start at [2] +local sample = math.min(#titles - 1, 3) +local line = "Из " .. feed_name .. ". " +for i = 2, 1 + sample do + line = line .. titles[i] .. ". " +end +return jarvis.cmd.ok(line) diff --git a/resources/commands/scheduler/add_at.lua b/resources/commands/scheduler/add_at.lua new file mode 100644 index 0000000..63bef04 --- /dev/null +++ b/resources/commands/scheduler/add_at.lua @@ -0,0 +1,58 @@ +-- "напомни в 18:00 забрать ребёнка" +local phrase = (jarvis.context.phrase or ""):lower() + +local body = jarvis.text.strip_trigger(phrase, { + "напомни сегодня в", + "напомни в", + "разбуди в", + "поставь напоминалку на", + "remind me at", + "wake me at", + "нагадай о", + "розбуди о", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- Parse "HH:MM " or "HH MM " +local h, m, rest = body:match("^(%d%d?)%s*[:%-%s]%s*(%d%d?)%s*(.*)$") +if not h then + h, rest = body:match("^(%d%d?)%s*(.*)$") + if not h then + jarvis.speak("Не понял время.") + jarvis.audio.play_error() + return { chain = false } + end + m = "00" +end +m = m or "00" + +local hn = tonumber(h) +local mn = tonumber(m) +if hn < 0 or hn > 23 or mn < 0 or mn > 59 then + jarvis.speak("Время вне диапазона.") + jarvis.audio.play_error() + return { chain = false } +end + +local schedule = string.format("at %02d:%02d", hn, mn) +local text_body = (rest or ""):gsub("^[%s,:%.]+", ""):gsub("%s+$", "") +if text_body == "" then text_body = "Напоминание." end + +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Reminder", + schedule = schedule, + action = { type = "speak", text = text_body } + }) +end) +if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak(string.format("Напомню в %02d:%02d.", hn, mn)) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/add_daily.lua b/resources/commands/scheduler/add_daily.lua new file mode 100644 index 0000000..bb528b4 --- /dev/null +++ b/resources/commands/scheduler/add_daily.lua @@ -0,0 +1,62 @@ +-- "каждый день в 9:00 делай briefing" / "каждое утро в 8 напоминай зарядку" +local phrase = (jarvis.context.phrase or ""):lower() + +local body = jarvis.text.strip_trigger(phrase, { + "каждое утро в", + "каждый день в", + "ежедневно в", + "по утрам в", + "every day at", + "daily at", + "щодня о", + "кожен день о", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- Parse "HH:MM " or "HH " +local h, m, rest = body:match("^(%d%d?)%s*[:%-]%s*(%d%d?)%s*(.*)$") +if not h then + h, rest = body:match("^(%d%d?)%s*(.*)$") + m = "00" +end + +if not h then + jarvis.speak("Не понял время.") + jarvis.audio.play_error() + return { chain = false } +end + +local hn = tonumber(h) +local mn = tonumber(m) or 0 +if hn < 0 or hn > 23 or mn < 0 or mn > 59 then + jarvis.speak("Время вне диапазона.") + jarvis.audio.play_error() + return { chain = false } +end + +local schedule = string.format("daily %02d:%02d", hn, mn) +local text_body = (rest or ""):gsub("^напоминай%s+", "") + :gsub("^напомни%s+", "") + :gsub("^делай%s+", "") + :gsub("^[%s,:%.]+", "") + :gsub("%s+$", "") +if text_body == "" then text_body = "Доброе утро." end + +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Daily", + schedule = schedule, + action = { type = "speak", text = text_body } + }) +end) +if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak(string.format("Каждый день в %02d:%02d.", hn, mn)) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/add_recurring.lua b/resources/commands/scheduler/add_recurring.lua new file mode 100644 index 0000000..92ee1d3 --- /dev/null +++ b/resources/commands/scheduler/add_recurring.lua @@ -0,0 +1,74 @@ +-- "каждые 2 часа напоминай попить воды" / "каждый час напоминай размяться" +local phrase = (jarvis.context.phrase or ""):lower() + +local body = jarvis.text.strip_trigger(phrase, { + "напоминай каждые", + "напоминай каждый", + "каждые", + "каждый час", + "remind me every", + "every", + "кожні", + "нагадуй кожні", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +-- Parse " " or "час " (implicit 1) +local n_str, unit, rest = body:match("^(%d+)%s+(%S+)%s*(.*)$") +local n, unit_norm, sample + +if n_str then + n = tonumber(n_str) + if unit:match("^минут") or unit:match("^minute") then + unit_norm = "minutes" + sample = n .. " минут" + elseif unit:match("^час") or unit:match("^hour") then + unit_norm = "hours" + sample = n .. " час" .. (n == 1 and "" or (n < 5 and "а" or "ов")) + else + jarvis.speak("Не понял единицу.") + jarvis.audio.play_error() + return { chain = false } + end +else + -- "каждый час напоминай..." + local single, rest2 = body:match("^(%a+)%s*(.*)$") + if single and (single == "час" or single == "минуту" or single == "минута") then + n = 1 + unit_norm = (single == "час") and "hours" or "minutes" + sample = (single == "час") and "час" or "минуту" + rest = rest2 + else + jarvis.speak("Не понял через сколько повторять.") + jarvis.audio.play_error() + return { chain = false } + end +end + +-- Strip optional reminder verb +local text_body = (rest or ""):gsub("^напоминай%s+", "") + :gsub("^напомни%s+", "") + :gsub("^remind me%s+", "") + :gsub("^[%s,:%.]+", "") + :gsub("%s+$", "") +if text_body == "" then text_body = "Напоминание." end + +local schedule = string.format("every %d %s", n, unit_norm) +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Recurring", + schedule = schedule, + action = { type = "speak", text = text_body } + }) +end) +if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak("Буду напоминать каждые " .. sample .. ".") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/add_reminder.lua b/resources/commands/scheduler/add_reminder.lua new file mode 100644 index 0000000..4d47b9c --- /dev/null +++ b/resources/commands/scheduler/add_reminder.lua @@ -0,0 +1,102 @@ +-- "напомни мне через 5 минут выключить кофеварку" +local phrase = (jarvis.context.phrase or ""):lower() + +-- Strip the trigger +local body = jarvis.text.strip_trigger(phrase, { + "напомни мне через", + "напомни через", + "поставь напоминалку через", + "поставь напоминание через", + "разбуди через", + "remind me in", + "set a reminder in", + "нагадай через", + "постав нагадування через", +}) + +body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if body == "" then + jarvis.speak("Через сколько и про что напомнить?") + jarvis.audio.play_error() + return { chain = false } +end + +-- Parse " " +-- N can be digit or word (один, два, ...) — for v1 only digits +local n_str, unit, rest = body:match("^(%d+)%s+(%S+)%s*(.*)$") +if not n_str then + -- maybe "час" / "минуту" (singular) + local single_unit, rest2 = body:match("^([%a]+)%s*(.*)$") + if single_unit then + local map = { + ["час"] = "1 hours", + ["минуту"] = "1 minutes", + ["секунду"] = "1 seconds", + } + if map[single_unit] then + local schedule = "in " .. map[single_unit] + local text_body = rest2:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + if text_body == "" then text_body = "Напоминание." end + local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Reminder", + schedule = schedule, + action = { type = "speak", text = text_body } + }) + end) + if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось.") + jarvis.audio.play_error() + return { chain = false } + end + jarvis.speak("Напомню через " .. single_unit .. ".") + jarvis.audio.play_ok() + return { chain = false } + end + end + jarvis.speak("Не понял через сколько. Скажите например: через 5 минут.") + jarvis.audio.play_error() + return { chain = false } +end + +local n = tonumber(n_str) +local unit_norm +local sample +if unit:match("^минут") or unit == "минуты" or unit == "минуту" or unit:match("^minute") then + unit_norm = "minutes" + sample = n .. " минут" +elseif unit:match("^час") or unit:match("^hour") then + unit_norm = "hours" + sample = n .. " час" .. (n == 1 and "" or (n < 5 and "а" or "ов")) +elseif unit:match("^секунд") or unit:match("^second") then + unit_norm = "seconds" + sample = n .. " секунд" +else + jarvis.speak("Не понял единицу. Минуты или часы.") + jarvis.audio.play_error() + return { chain = false } +end + +local schedule = string.format("in %d %s", n, unit_norm) +local text_body = rest:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") +if text_body == "" then text_body = "Напоминание." end + +local ok, err = pcall(function() + jarvis.scheduler.add({ + name = "Reminder", + schedule = schedule, + action = { type = "speak", text = text_body } + }) +end) +if not ok then + jarvis.log("warn", "scheduler.add: " .. tostring(err)) + jarvis.speak("Не получилось добавить напоминание.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak("Напомню через " .. sample .. ".") +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/scheduler/cancel_by_text.lua b/resources/commands/scheduler/cancel_by_text.lua new file mode 100644 index 0000000..b4471d3 --- /dev/null +++ b/resources/commands/scheduler/cancel_by_text.lua @@ -0,0 +1,27 @@ +-- "Отмени напоминание про воду" / "удали задачу про разминку" +local phrase = (jarvis.context.phrase or ""):lower() + +local query = jarvis.text.strip_trigger(phrase, { + "отмени напоминание про", + "отмени напоминание о", + "удали задачу про", + "удали задачу о", + "отмени про", + "забудь напоминание про", + "cancel reminder about", +}) +query = query:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if query == "" then + return jarvis.cmd.error("Что именно отменить?") +end + +local n = jarvis.scheduler.remove_by_text(query) +if n == 0 then + return jarvis.cmd.not_found("Не нашёл напоминаний про " .. query .. ".") +end + +if n == 1 then + return jarvis.cmd.ok("Отменил напоминание.") +end +return jarvis.cmd.ok(string.format("Отменил %d напоминаний про %s.", n, query)) diff --git a/resources/commands/scheduler/clear.lua b/resources/commands/scheduler/clear.lua new file mode 100644 index 0000000..3e8796a --- /dev/null +++ b/resources/commands/scheduler/clear.lua @@ -0,0 +1,5 @@ +local n = jarvis.scheduler.clear() +if n == 0 then + return jarvis.cmd.ok("В расписании и так пусто.") +end +return jarvis.cmd.ok(string.format("Удалил %d задач.", n)) diff --git a/resources/commands/scheduler/command.toml b/resources/commands/scheduler/command.toml new file mode 100644 index 0000000..9560038 --- /dev/null +++ b/resources/commands/scheduler/command.toml @@ -0,0 +1,125 @@ +# IMBA-5: Proactive scheduler — recurring reminders, daily briefings, intervals. + +[[commands]] +id = "scheduler.add_reminder" +type = "lua" +script = "add_reminder.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "напомни мне через", "напомни через", + "поставь напоминалку через", "поставь напоминание через", + "разбуди через", +] +en = ["remind me in", "set a reminder in"] + + +[[commands]] +id = "scheduler.add_at" +type = "lua" +script = "add_at.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "напомни в", + "напомни сегодня в", + "разбуди в", + "поставь напоминалку на", +] +en = ["remind me at", "wake me at"] + + +[[commands]] +id = "scheduler.add_recurring" +type = "lua" +script = "add_recurring.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "каждые", + "каждый час", + "каждые два часа", + "напоминай каждые", + "напоминай каждый", +] +en = ["every", "remind me every"] + + +[[commands]] +id = "scheduler.add_daily" +type = "lua" +script = "add_daily.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "каждый день в", + "каждое утро в", + "ежедневно в", + "по утрам в", +] +en = ["every day at", "daily at"] + + +[[commands]] +id = "scheduler.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "что у меня запланировано", + "какие напоминания", + "покажи расписание", + "какие задачи у меня", + "что в расписании", +] +en = ["what's on my schedule", "show schedule", "list reminders"] + + +[[commands]] +id = "scheduler.clear" +type = "lua" +script = "clear.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "очисти расписание", + "удали все напоминания", + "отмени все напоминания", + "сбрось расписание", +] +en = ["clear schedule", "cancel all reminders"] + + +[[commands]] +id = "scheduler.cancel_by_text" +type = "lua" +script = "cancel_by_text.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "отмени напоминание про", + "отмени напоминание о", + "удали задачу про", + "удали задачу о", + "отмени про", + "забудь напоминание про", +] +en = [ + "cancel reminder about", + "remove task about", +] diff --git a/resources/commands/scheduler/list.lua b/resources/commands/scheduler/list.lua new file mode 100644 index 0000000..2410a57 --- /dev/null +++ b/resources/commands/scheduler/list.lua @@ -0,0 +1,20 @@ +local tasks = jarvis.scheduler.list() +if #tasks == 0 then + jarvis.speak("Расписание пустое.") + jarvis.audio.play_ok() + return { chain = false } +end + +local sample = math.min(#tasks, 5) +local line = string.format("Запланировано %d. ", #tasks) +for i = 1, sample do + local t = tasks[i] + local what = (t.action and t.action.text) or t.name or "задача" + line = line .. t.schedule_human .. ": " .. what + if i < sample then line = line .. ". " end +end +line = line .. "." + +jarvis.speak(line) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/screenshot/command.toml b/resources/commands/screenshot/command.toml new file mode 100644 index 0000000..c04a7ae --- /dev/null +++ b/resources/commands/screenshot/command.toml @@ -0,0 +1,36 @@ +# Quick screenshot pack — capture screen, save somewhere useful. +# Unlike vision/, no LLM call — just the capture. Faster + works offline. + +[[commands]] +id = "screenshot.to_clipboard" +type = "lua" +script = "to_clipboard.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "сделай скриншот", + "сделай скрин", + "скопируй экран", + "скриншот в буфер", + "захвати экран", +] +en = ["take a screenshot", "screenshot", "capture screen"] + + +[[commands]] +id = "screenshot.to_file" +type = "lua" +script = "to_file.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "сохрани скриншот", + "сохрани скрин", + "скриншот в файл", + "сохрани экран", +] +en = ["save screenshot", "screenshot to file"] diff --git a/resources/commands/screenshot/to_clipboard.lua b/resources/commands/screenshot/to_clipboard.lua new file mode 100644 index 0000000..56d07d7 --- /dev/null +++ b/resources/commands/screenshot/to_clipboard.lua @@ -0,0 +1,25 @@ +-- Capture full screen → put PNG into clipboard via PowerShell. +-- One-liner with System.Drawing + System.Windows.Forms.Clipboard.SetImage. + +local ps = [[ +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds +$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) +[System.Windows.Forms.Clipboard]::SetImage($bmp) +$g.Dispose(); $bmp.Dispose() +]] + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', + ps:gsub('"', '\\"'):gsub("\r?\n", "; ") +)) + +if not res.success then + jarvis.log("warn", "screenshot to clipboard failed: " .. tostring(res.stderr):sub(1, 200)) + return jarvis.cmd.error("Не получилось сделать скриншот.") +end + +return jarvis.cmd.ok("Скриншот в буфере.") diff --git a/resources/commands/screenshot/to_file.lua b/resources/commands/screenshot/to_file.lua new file mode 100644 index 0000000..e0266da --- /dev/null +++ b/resources/commands/screenshot/to_file.lua @@ -0,0 +1,36 @@ +-- Capture full screen → save PNG under ~/Pictures/Screenshots/jarvis-YYYYMMDD-HHMMSS.png +local t = jarvis.context.time +local stamp = string.format("%04d%02d%02d-%02d%02d%02d", + t.year, t.month, t.day, t.hour, t.minute, t.second or 0) + +local userprofile = jarvis.system.env("USERPROFILE") or "C:\\Users\\Public" +local dir = userprofile .. "\\Pictures\\Screenshots" +local path = string.format("%s\\jarvis-%s.png", dir, stamp) + +-- Ensure dir exists +jarvis.fs.mkdir(dir) + +local escaped = path:gsub("'", "''") +local ps = string.format([[ +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds +$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) +$bmp.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png) +$g.Dispose(); $bmp.Dispose() +]], escaped) + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', + ps:gsub('"', '\\"'):gsub("\r?\n", "; ") +)) + +if not res.success then + jarvis.log("warn", "screenshot to file failed: " .. tostring(res.stderr):sub(1, 200)) + return jarvis.cmd.error("Не получилось сохранить скриншот.") +end + +jarvis.system.notify("Скриншот", path) +return jarvis.cmd.ok("Скриншот сохранён в папку Скриншоты.") diff --git a/resources/commands/self_check/command.toml b/resources/commands/self_check/command.toml new file mode 100644 index 0000000..2aed789 --- /dev/null +++ b/resources/commands/self_check/command.toml @@ -0,0 +1,37 @@ +# Self-check — quick "are you alive" probes. Useful for testing mic chain. + +[[commands]] +id = "selfcheck.ping" +type = "lua" +script = "ping.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "ты тут", + "ты слышишь", + "ты меня слышишь", + "пинг", + "ты живой", + "ты на месте", + "проверка связи", +] +en = ["are you there", "ping", "can you hear me"] + + +[[commands]] +id = "selfcheck.smoke" +type = "lua" +script = "smoke.lua" +sandbox = "standard" +timeout = 8000 + +[commands.phrases] +ru = [ + "проверь себя", + "самопроверка", + "сделай smoke тест", + "проверка всех систем", +] +en = ["self check", "smoke test", "check yourself"] diff --git a/resources/commands/self_check/ping.lua b/resources/commands/self_check/ping.lua new file mode 100644 index 0000000..9f19dfd --- /dev/null +++ b/resources/commands/self_check/ping.lua @@ -0,0 +1,11 @@ +-- Cheap response so user knows mic chain works without invoking LLM/network. +local responses = { + "Да, сэр.", + "Здесь, сэр.", + "Слушаю.", + "К вашим услугам.", + "На месте.", +} +math.randomseed(os.time() + (jarvis.context.time.minute or 0)) +local idx = math.random(1, #responses) +return jarvis.cmd.ok(responses[idx]) diff --git a/resources/commands/self_check/smoke.lua b/resources/commands/self_check/smoke.lua new file mode 100644 index 0000000..52f0f6c --- /dev/null +++ b/resources/commands/self_check/smoke.lua @@ -0,0 +1,46 @@ +-- Smoke test: verify every major subsystem responds. +-- Useful for "is the install OK" after fresh setup. +local h = jarvis.health() + +local checks = {} + +-- 1. TTS configured +if h.tts_backend and h.tts_backend ~= "—" then + table.insert(checks, "TTS " .. h.tts_backend .. " активен") +end + +-- 2. LLM configured +if h.llm_backend and h.llm_backend ~= "none" then + table.insert(checks, "LLM " .. h.llm_backend .. " подключён") +else + table.insert(checks, "ВНИМАНИЕ: LLM не настроен") +end + +-- 3. Memory + scheduler files exist +if h.memory_facts ~= nil then + table.insert(checks, "хранилище памяти работает") +end +if h.scheduled_tasks ~= nil then + table.insert(checks, "планировщик работает") +end + +-- 4. Profile + i18n +if h.active_profile then + table.insert(checks, "профиль " .. h.active_profile) +end + +-- 5. Quick HTTP smoke +local r = pcall(function() + local resp = jarvis.http.get("https://api.ipify.org") + return resp and resp.ok +end) +if r then + table.insert(checks, "интернет есть") +end + +if #checks == 0 then + return jarvis.cmd.error("Все системы молчат — что-то не так.") +end + +local line = "Самопроверка: " .. table.concat(checks, ", ") .. "." +return jarvis.cmd.ok(line) diff --git a/resources/commands/sleep_timer/cancel.lua b/resources/commands/sleep_timer/cancel.lua new file mode 100644 index 0000000..79b14be --- /dev/null +++ b/resources/commands/sleep_timer/cancel.lua @@ -0,0 +1,9 @@ +-- Cancel both shutdown and pause timers (best-effort, ok if one was inactive). +local removed_pause = jarvis.scheduler.remove("sleep_timer_pause") +local sd_res = jarvis.system.exec("shutdown.exe /a") +local sd_ok = sd_res and sd_res.success + +if removed_pause or sd_ok then + return jarvis.cmd.ok("Таймер отменён.") +end +return jarvis.cmd.not_found("Активных таймеров нет.") diff --git a/resources/commands/sleep_timer/command.toml b/resources/commands/sleep_timer/command.toml new file mode 100644 index 0000000..9eed3af --- /dev/null +++ b/resources/commands/sleep_timer/command.toml @@ -0,0 +1,53 @@ +# Sleep timer — pause media + optional shutdown after N minutes. + +[[commands]] +id = "sleep_timer.pause_in" +type = "lua" +script = "pause_in.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "выключи музыку через", + "приостанови музыку через", + "пауза через", + "пауза музыки через", +] +en = ["pause music in", "pause in"] + + +[[commands]] +id = "sleep_timer.shutdown_in" +type = "lua" +script = "shutdown_in.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "выключи компьютер через", + "выключи пк через", + "выключи комп через", + "таймер выключения", + "ложусь спать через", +] +en = ["shutdown in", "sleep timer"] + + +[[commands]] +id = "sleep_timer.cancel" +type = "lua" +script = "cancel.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "отмени таймер выключения", + "отмени таймер сна", + "отмени паузу", + "не выключай компьютер", + "не выключай пк", +] +en = ["cancel sleep timer", "cancel shutdown"] diff --git a/resources/commands/sleep_timer/pause_in.lua b/resources/commands/sleep_timer/pause_in.lua new file mode 100644 index 0000000..cc87802 --- /dev/null +++ b/resources/commands/sleep_timer/pause_in.lua @@ -0,0 +1,58 @@ +-- "Выключи музыку через 30 минут" → scheduler one-shot фраза-команда "пауза" +local phrase = (jarvis.context.phrase or ""):lower() +local n_str, unit = phrase:match("(%d+)%s+(%S+)") +if not n_str then + return jarvis.cmd.error("Не понял через сколько.") +end +local n = tonumber(n_str) +local secs +if unit:match("^минут") then secs = "in " .. n .. " minutes" +elseif unit:match("^час") then secs = "in " .. n .. " hours" +elseif unit:match("^секунд") then secs = "in " .. n .. " seconds" +else + return jarvis.cmd.error("Не понял единицу.") +end + +-- Build path to media_keys/_media.ps1 (lives next door) +local cmd_path = jarvis.context.command_path +local helper = cmd_path:gsub("sleep_timer$", "media_keys") .. "\\_media.ps1" +helper = helper:gsub("/", "\\") + +-- VK_MEDIA_PLAY_PAUSE = 0xB3 = 179 +local lua_path = cmd_path:gsub("\\", "/") .. "/_pause_lua.lua" +-- Simpler: just speak "пауза" and let agent router handle it? No — agent +-- router only fires for unmatched commands. Better: directly invoke media key +-- via a one-shot Lua script. + +local script_path = cmd_path .. "\\_fire_pause.lua" +script_path = script_path:gsub("/", "\\") + +-- Write the helper script once +local exists = jarvis.fs.is_file(script_path) +if not exists then + jarvis.fs.write(script_path, string.format([[ +local helper = "%s" +jarvis.system.exec(string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%%s" 179', + helper +)) +return { chain = false } +]], helper:gsub("\\", "\\\\"))) +end + +local ok, err = pcall(function() + jarvis.scheduler.remove("sleep_timer_pause") + jarvis.scheduler.add({ + id = "sleep_timer_pause", + name = "Sleep timer: pause music", + schedule = secs, + action = { type = "lua", script_path = script_path }, + }) +end) + +if not ok then + jarvis.log("warn", "sleep_timer.pause_in: " .. tostring(err)) + return jarvis.cmd.error("Не получилось.") +end + +return jarvis.cmd.ok("Поставлю на паузу через " .. n_str .. " " .. unit .. ".") diff --git a/resources/commands/sleep_timer/shutdown_in.lua b/resources/commands/sleep_timer/shutdown_in.lua new file mode 100644 index 0000000..be3be33 --- /dev/null +++ b/resources/commands/sleep_timer/shutdown_in.lua @@ -0,0 +1,46 @@ +-- "Выключи компьютер через 30 минут" → shutdown.exe /s /t +-- Использует штатный shutdown с обратным таймером. +local phrase = (jarvis.context.phrase or ""):lower() +local n_str, unit = phrase:match("(%d+)%s+(%S+)") +if not n_str then + return jarvis.cmd.error("Не понял через сколько.") +end +local n = tonumber(n_str) +local secs +if unit:match("^минут") then secs = n * 60 +elseif unit:match("^час") then secs = n * 3600 +elseif unit:match("^секунд") then secs = n +else + return jarvis.cmd.error("Не понял единицу. Минуты или часы.") +end + +if secs > 86400 then + return jarvis.cmd.error("Слишком большое значение — максимум 24 часа.") +end + +local cmd = string.format( + 'shutdown.exe /s /t %d /c "Sleep timer fired"', + secs +) +local res = jarvis.system.exec(cmd) +if not res.success then + return jarvis.cmd.error("Не получилось поставить таймер.") +end + +-- Pluralisation +local label +if unit:match("^минут") then + local last = n % 10 + if last == 1 and n % 100 ~= 11 then label = n .. " минуту" + elseif last >= 2 and last <= 4 and (n % 100 < 10 or n % 100 >= 20) then label = n .. " минуты" + else label = n .. " минут" end +elseif unit:match("^час") then + local last = n % 10 + if last == 1 and n % 100 ~= 11 then label = n .. " час" + elseif last >= 2 and last <= 4 and (n % 100 < 10 or n % 100 >= 20) then label = n .. " часа" + else label = n .. " часов" end +else + label = n .. " секунд" +end + +return jarvis.cmd.ok("Выключу компьютер через " .. label .. ". Скажите 'отмени таймер выключения' чтобы передумать.") diff --git a/resources/commands/sound_panel/command.toml b/resources/commands/sound_panel/command.toml new file mode 100644 index 0000000..35f4ded --- /dev/null +++ b/resources/commands/sound_panel/command.toml @@ -0,0 +1,19 @@ +[[commands]] +id = "open_sound_panel" +type = "lua" +script = "open.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "открой настройки звука", + "панель звука", + "настройки микрофона", + "управление звуком", +] +en = [ + "open sound settings", + "sound panel", + "microphone settings", +] diff --git a/resources/commands/sound_panel/open.lua b/resources/commands/sound_panel/open.lua new file mode 100644 index 0000000..d13187b --- /dev/null +++ b/resources/commands/sound_panel/open.lua @@ -0,0 +1,3 @@ +jarvis.system.exec('rundll32.exe shell32.dll,Control_RunDLL mmsys.cpl,,1') +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/spelling/command.toml b/resources/commands/spelling/command.toml new file mode 100644 index 0000000..4c4e11e --- /dev/null +++ b/resources/commands/spelling/command.toml @@ -0,0 +1,18 @@ +[[commands]] +id = "spell_out" +type = "lua" +script = "spell.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "произнеси по буквам", + "продиктуй по буквам", + "произнеси буквы", + "по буквам", +] +en = [ + "spell out", + "spell it", +] diff --git a/resources/commands/spelling/spell.lua b/resources/commands/spelling/spell.lua new file mode 100644 index 0000000..fbf6b6f --- /dev/null +++ b/resources/commands/spelling/spell.lua @@ -0,0 +1,29 @@ +local lang = jarvis.context.language +local triggers = { + "продиктуй по буквам", "произнеси по буквам", "произнеси буквы", "по буквам", + "spell out", "spell it", + "вимов по буквах", +} +local word = jarvis.text.strip_trigger(jarvis.context.phrase or "", triggers) +if word == "" then + jarvis.system.notify("Spelling", lang == "ru" and "Что произнести?" or "What to spell?") + jarvis.audio.play_error() + return { chain = false } +end + +local letters = {} +for ch in word:gmatch(".") do + if ch ~= " " then table.insert(letters, ch) end +end +if #letters == 0 then + jarvis.audio.play_error() + return { chain = false } +end + +local spoken = table.concat(letters, ". ") .. "." + +jarvis.system.notify(lang == "ru" and "По буквам" or "Spelling", + word .. "\n" .. spoken) +jarvis.speak(spoken, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/ssl_check/check.lua b/resources/commands/ssl_check/check.lua new file mode 100644 index 0000000..e6da68a --- /dev/null +++ b/resources/commands/ssl_check/check.lua @@ -0,0 +1,79 @@ +-- "Проверь сертификат example.com" → PowerShell TCP connect + parse expiry. +local phrase = (jarvis.context.phrase or ""):lower() +local domain = jarvis.text.strip_trigger(phrase, { + "когда истекает сертификат", + "проверь сертификат", + "сертификат сайта", + "проверь tls", + "проверь ssl", + "check ssl", + "check tls", + "check certificate", + "перевір сертифікат", +}) +domain = domain:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") +-- Preserve original casing +if domain ~= "" then + local start = phrase:find(domain, 1, true) + if start then domain = phrase:sub(start, start + #domain - 1) end +end + +if domain == "" then + return jarvis.cmd.error("Какой домен проверить?") +end + +-- Strip "https://" if user said it +domain = domain:gsub("^https?://", ""):gsub("/$", ""):gsub("/.*", "") + +local ps = string.format([[ +$ErrorActionPreference = 'Stop' +try { + $tcp = New-Object Net.Sockets.TcpClient('%s', 443) + $ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, { $true }) + $ssl.AuthenticateAsClient('%s') + $cert = $ssl.RemoteCertificate + $cert2 = New-Object Security.Cryptography.X509Certificates.X509Certificate2($cert) + $expiry = $cert2.NotAfter + $days = ($expiry - (Get-Date)).Days + Write-Output ('{0}|{1}|{2}' -f $days, $expiry.ToString('yyyy-MM-dd'), $cert2.Issuer) + $ssl.Close() + $tcp.Close() +} catch { + Write-Output ('ERR|' + $_.Exception.Message) +} +]], domain, domain) + +local res = jarvis.system.exec(string.format( + 'powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'):gsub("\r?\n", "; ") +)) + +if not res.success then + return jarvis.cmd.error("Не получилось проверить.") +end + +local out = (res.stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") +if out:match("^ERR|") then + local msg = out:sub(5):sub(1, 100) + return jarvis.cmd.error("Не получилось: " .. msg) +end + +local days, date, issuer = out:match("(%-?%d+)|([%d%-]+)|(.+)") +if not days then + return jarvis.cmd.error("Не распарсил ответ.") +end + +local issuer_short = issuer:match("CN=([^,]+)") or issuer:sub(1, 30) +local days_n = tonumber(days) +local urgency = "" +if days_n < 0 then + urgency = "Сертификат уже просрочен!" +elseif days_n < 7 then + urgency = "СКОРО! " +elseif days_n < 30 then + urgency = "Скоро. " +end + +return jarvis.cmd.ok(string.format( + "%sСертификат %s истекает через %s дней (%s). Выдан: %s.", + urgency, domain, days, date, issuer_short +)) diff --git a/resources/commands/ssl_check/command.toml b/resources/commands/ssl_check/command.toml new file mode 100644 index 0000000..4829b9d --- /dev/null +++ b/resources/commands/ssl_check/command.toml @@ -0,0 +1,18 @@ +# SSL/TLS certificate expiry check via PowerShell. + +[[commands]] +id = "ssl.check" +type = "lua" +script = "check.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "проверь сертификат", + "когда истекает сертификат", + "проверь tls", + "проверь ssl", + "сертификат сайта", +] +en = ["check ssl", "check tls", "check certificate"] diff --git a/resources/commands/stocks/command.toml b/resources/commands/stocks/command.toml new file mode 100644 index 0000000..e1ef3a9 --- /dev/null +++ b/resources/commands/stocks/command.toml @@ -0,0 +1,31 @@ +# Stocks — query Moscow Exchange (MOEX) for share price. +# API: https://iss.moex.com/iss/engines/stock/markets/shares/securities/.json +# Free, no key, public. + +[[commands]] +id = "stocks_quote" +type = "lua" +script = "quote.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = [ + "сколько сейчас стоит", + "цена акций", + "котировка", + "сколько стоит сбер", + "сколько стоит газпром", + "сколько стоит лукойл", + "сколько стоит яндекс", + "сколько стоит тинькофф", + "сколько стоит втб", + "сколько стоит магнит", + "сколько стоит мтс", + "сколько стоит роснефть", +] +en = [ + "stock price", + "share price", + "quote for", +] diff --git a/resources/commands/stocks/quote.lua b/resources/commands/stocks/quote.lua new file mode 100644 index 0000000..76b4080 --- /dev/null +++ b/resources/commands/stocks/quote.lua @@ -0,0 +1,119 @@ +-- "Сколько сейчас Сбер" / "котировка газпром" — query MOEX ISS public API. +local phrase = (jarvis.context.phrase or ""):lower() + +-- Common name → ticker map. +local TICKERS = { + ["сбер"] = "SBER", ["сбербанк"] = "SBER", + ["газпром"] = "GAZP", + ["лукойл"] = "LKOH", + ["яндекс"] = "YDEX", ["yandex"] = "YDEX", + ["вк"] = "VKCO", ["вконтакте"] = "VKCO", + ["втб"] = "VTBR", + ["магнит"] = "MGNT", + ["мтс"] = "MTSS", + ["роснефть"] = "ROSN", + ["новатэк"] = "NVTK", + ["норникель"] = "GMKN", ["норильск"] = "GMKN", + ["полюс"] = "PLZL", + ["тинькофф"] = "T", ["тбанк"] = "T", ["т банк"] = "T", + ["сургутнефтегаз"] = "SNGS", + ["мосбиржа"] = "MOEX", ["биржа"] = "MOEX", + ["x5"] = "X5", ["икс пять"] = "X5", + ["алроса"] = "ALRS", + ["русал"] = "RUAL", + ["мечел"] = "MTLR", + ["фосагро"] = "PHOR", + ["сегежа"] = "SGZH", + ["белуга"] = "BELU", +} + +-- find ticker +local ticker, ticker_name +for name, tk in pairs(TICKERS) do + if phrase:find(name, 1, true) then + ticker = tk + ticker_name = name + break + end +end + +-- Allow direct ticker mention "сколько SBER" etc. +if not ticker then + local maybe = phrase:upper():match("([A-Z][A-Z][A-Z]+)") + if maybe then ticker = maybe; ticker_name = maybe end +end + +if not ticker then + jarvis.speak("Не понял какую бумагу. Скажите название.") + jarvis.audio.play_error() + return { chain = false } +end + +local url = string.format( + "https://iss.moex.com/iss/engines/stock/markets/shares/securities/%s.json?iss.meta=off", + ticker +) + +local data = jarvis.http.json(url) +if not data then + jarvis.speak("Не получилось получить котировки.") + jarvis.audio.play_error() + return { chain = false } +end + +-- MOEX ISS returns parallel arrays: columns + data rows. We want marketdata.LAST. +local md = data.marketdata +if not md or not md.columns or not md.data then + jarvis.speak("Биржа вернула пустой ответ.") + jarvis.audio.play_error() + return { chain = false } +end + +local cols = md.columns +local rows = md.data + +-- find indexes +local idx_last, idx_change, idx_board +for i, c in ipairs(cols) do + if c == "LAST" then idx_last = i end + if c == "LASTTOPREVPRICE" then idx_change = i end + if c == "BOARDID" then idx_board = i end +end + +-- pick the TQBR (main board) row if present, else first non-nil LAST +local last, change +for _, row in ipairs(rows) do + local board = idx_board and row[idx_board] + if board == "TQBR" and row[idx_last] then + last = row[idx_last] + change = idx_change and row[idx_change] + break + end +end +if not last then + for _, row in ipairs(rows) do + if row[idx_last] then + last = row[idx_last] + change = idx_change and row[idx_change] + break + end + end +end + +if not last then + jarvis.speak("По " .. ticker_name .. " сегодня нет торгов.") + jarvis.audio.play_not_found() + return { chain = false } +end + +local rub = string.format("%.2f", last):gsub("%.", " и ") +local line = string.format("%s — %s рубля.", ticker_name, rub) +if change and tonumber(change) then + local pct = tonumber(change) + local sign = pct >= 0 and "плюс" or "минус" + line = line .. string.format(" Сегодня %s %.2f процента.", sign, math.abs(pct)) +end + +jarvis.speak(line) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/stopwatch/command.toml b/resources/commands/stopwatch/command.toml new file mode 100644 index 0000000..30c684c --- /dev/null +++ b/resources/commands/stopwatch/command.toml @@ -0,0 +1,34 @@ +[[commands]] +id = "stopwatch_start" +type = "lua" +script = "ctl.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["засеки время", "запусти секундомер", "старт секундомер", "включи секундомер"] +en = ["start stopwatch", "start timer"] + + +[[commands]] +id = "stopwatch_check" +type = "lua" +script = "ctl.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["сколько прошло", "сколько времени прошло", "проверь секундомер"] +en = ["how long has it been", "check stopwatch"] + + +[[commands]] +id = "stopwatch_stop" +type = "lua" +script = "ctl.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["стоп секундомер", "останови секундомер", "выключи секундомер"] +en = ["stop stopwatch", "stop timer"] diff --git a/resources/commands/stopwatch/ctl.lua b/resources/commands/stopwatch/ctl.lua new file mode 100644 index 0000000..2e0edb0 --- /dev/null +++ b/resources/commands/stopwatch/ctl.lua @@ -0,0 +1,53 @@ +local lang = jarvis.context.language +local cmd_id = jarvis.context.command_id + +local function speak(text) + local sapi = text:gsub("'", "''"):gsub("\r?\n", " ") + local ps = string.format( + [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], + sapi + ) + jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) +end + +local function format_elapsed(sec) + sec = math.floor(sec) + if sec < 60 then return sec .. (lang == "ru" and " секунд" or " seconds") end + if sec < 3600 then + local m = math.floor(sec / 60); local s = sec % 60 + return string.format(lang == "ru" and "%d мин %d сек" or "%d min %d sec", m, s) + end + local h = math.floor(sec / 3600); local m = math.floor((sec % 3600) / 60); local s = sec % 60 + return string.format(lang == "ru" and "%d ч %d мин %d сек" or "%d h %d m %d s", h, m, s) +end + +if cmd_id == "stopwatch_start" then + jarvis.state.set("stopwatch_start_ts", jarvis.context.time.timestamp) + jarvis.system.notify("Stopwatch", lang == "ru" and "Старт" or "Started") + speak(lang == "ru" and "Секундомер запущен" or "Stopwatch started") + jarvis.audio.play_ok() + return { chain = false } +end + +local started = jarvis.state.get("stopwatch_start_ts") +if not started then + jarvis.system.notify("Stopwatch", lang == "ru" and "Не запущен" or "Not running") + speak(lang == "ru" and "Секундомер не запущен" or "Stopwatch is not running") + jarvis.audio.play_not_found() + return { chain = false } +end + +local elapsed = jarvis.context.time.timestamp - started +local human = format_elapsed(elapsed) + +if cmd_id == "stopwatch_stop" then + jarvis.state.set("stopwatch_start_ts", nil) + jarvis.system.notify("Stopwatch", (lang == "ru" and "Прошло: " or "Elapsed: ") .. human) + speak((lang == "ru" and "Прошло " or "Elapsed ") .. human) +else + jarvis.system.notify("Stopwatch", (lang == "ru" and "Прошло: " or "Elapsed: ") .. human) + speak((lang == "ru" and "Прошло " or "Elapsed ") .. human) +end + +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/summarize/command.toml b/resources/commands/summarize/command.toml new file mode 100644 index 0000000..7fd6139 --- /dev/null +++ b/resources/commands/summarize/command.toml @@ -0,0 +1,21 @@ +[[commands]] +id = "summarize_selection" +type = "lua" +script = "summarize.lua" +sandbox = "full" +timeout = 25000 + +[commands.phrases] +ru = [ + "суммируй", + "перескажи выделенное", + "кратко перескажи", + "о чём это", + "что выделил", + "перескажи это", +] +en = [ + "summarise this", + "summarize selection", + "tldr", +] diff --git a/resources/commands/summarize/summarize.lua b/resources/commands/summarize/summarize.lua new file mode 100644 index 0000000..9e7a247 --- /dev/null +++ b/resources/commands/summarize/summarize.lua @@ -0,0 +1,43 @@ +local lang = jarvis.context.language + +local copy_ps = [[Add-Type -Name K -Namespace W -MemberDefinition '[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte sc, uint flags, System.UIntPtr extra);'; [W.K]::keybd_event(0x11,0,0,[UIntPtr]::Zero); [W.K]::keybd_event(0x43,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 40; [W.K]::keybd_event(0x43,0,2,[UIntPtr]::Zero); [W.K]::keybd_event(0x11,0,2,[UIntPtr]::Zero); Start-Sleep -Milliseconds 220]] +jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', copy_ps:gsub('"', '\\"'))) + +local raw = jarvis.system.clipboard.get() or "" +local text = raw:gsub("^%s+", ""):gsub("%s+$", "") + +if #text < 20 then + jarvis.system.notify("Summarize", + lang == "ru" and "Сначала выдели текст и повтори" or "Select text first, then retry") + jarvis.audio.play_not_found() + return { chain = false } +end + +if #text > 8000 then + text = text:sub(1, 8000) .. " ..." +end + +local sys = lang == "ru" + and "Перескажи следующий текст одним абзацем (3-5 предложений). Сохрани ключевые факты и имена, без 'вот суммарно' и других вступлений." + or "Summarise the following text in 3-5 sentences. Keep key facts and names, no preamble." + +local reply = jarvis.llm( + { { role = "system", content = sys }, + { role = "user", content = text } }, + { max_tokens = 400, temperature = 0.3 } +) + +if not reply or reply == "" then + jarvis.system.notify("Summarize", lang == "ru" and "LLM не ответила" or "LLM failed") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.system.clipboard.set(reply) +jarvis.system.notify( + lang == "ru" and "Кратко" or "TL;DR", + reply:sub(1, 280) +) +jarvis.speak(reply, { lang = lang }) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/sysinfo/_sysinfo.ps1 b/resources/commands/sysinfo/_sysinfo.ps1 new file mode 100644 index 0000000..ae020a3 --- /dev/null +++ b/resources/commands/sysinfo/_sysinfo.ps1 @@ -0,0 +1,69 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet("battery","time","cpu","ram","disk","all")] + [string]$Topic +) + +$ErrorActionPreference = 'SilentlyContinue' + +function Get-BatteryInfo { + $b = Get-CimInstance -ClassName Win32_Battery + if (-not $b) { return "Батарея не обнаружена (видимо, десктоп)" } + $pct = [int]$b.EstimatedChargeRemaining + $status = switch ($b.BatteryStatus) { + 1 { "разряжается" } + 2 { "от сети" } + 3 { "заряжена" } + 4 { "низкий заряд" } + 5 { "критический заряд" } + default { "статус $($b.BatteryStatus)" } + } + return "Батарея: $pct%, $status" +} + +function Get-TimeInfo { + $ru = [System.Globalization.CultureInfo]::GetCultureInfo("ru-RU") + $now = Get-Date + return "Сейчас: " + $now.ToString("HH:mm, dd MMMM, dddd", $ru) +} + +function Get-CpuInfo { + $cpu = Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 + $load = (Get-CimInstance -ClassName Win32_PerfFormattedData_PerfOS_Processor -Filter "Name='_Total'" -ErrorAction SilentlyContinue).PercentProcessorTime + if ($null -eq $load) { $load = $cpu.LoadPercentage } + return "CPU: $($cpu.Name.Trim()), загрузка $load%" +} + +function Get-RamInfo { + $os = Get-CimInstance -ClassName Win32_OperatingSystem + $totalGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 1) + $freeGB = [math]::Round($os.FreePhysicalMemory / 1MB, 1) + $usedGB = [math]::Round($totalGB - $freeGB, 1) + $pct = [int](($usedGB / $totalGB) * 100) + return "RAM: $usedGB из $totalGB ГБ ($pct% занято)" +} + +function Get-DiskInfo { + $sys = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='$($env:SystemDrive)'" + if (-not $sys) { + $sys = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" | Select-Object -First 1 + } + $freeGB = [math]::Round($sys.FreeSpace / 1GB, 1) + $totalGB = [math]::Round($sys.Size / 1GB, 1) + return "Диск $($sys.DeviceID): свободно $freeGB из $totalGB ГБ" +} + +switch ($Topic) { + "battery" { Get-BatteryInfo } + "time" { Get-TimeInfo } + "cpu" { Get-CpuInfo } + "ram" { Get-RamInfo } + "disk" { Get-DiskInfo } + "all" { + Get-TimeInfo + Get-BatteryInfo + Get-CpuInfo + Get-RamInfo + Get-DiskInfo + } +} diff --git a/resources/commands/sysinfo/all.lua b/resources/commands/sysinfo/all.lua new file mode 100644 index 0000000..ad0f346 --- /dev/null +++ b/resources/commands/sysinfo/all.lua @@ -0,0 +1,14 @@ +local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic all', helper) +local res = jarvis.system.exec(cmd) +local text = (res.stdout or ""):gsub("[\r\n]+$", "") +if not res.success or text == "" then + jarvis.log("error", "sysinfo all failed: " .. tostring(res.stderr)) + return jarvis.cmd.error("Не получилось собрать состояние системы.") +end +jarvis.system.notify("Статус системы", text) +jarvis.log("info", text) +-- Multi-line output reads more naturally as a single sentence with commas +-- between metrics, so TTS doesn't over-pause on linebreaks. +local spoken = text:gsub("[\r\n]+", ", ") +return jarvis.cmd.ok(spoken) diff --git a/resources/commands/sysinfo/battery.lua b/resources/commands/sysinfo/battery.lua new file mode 100644 index 0000000..2820879 --- /dev/null +++ b/resources/commands/sysinfo/battery.lua @@ -0,0 +1,11 @@ +local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic battery', helper) +local res = jarvis.system.exec(cmd) +local text = (res.stdout or ""):gsub("[\r\n]+$", "") +if not res.success or text == "" then + jarvis.log("error", "battery info failed: " .. tostring(res.stderr)) + return jarvis.cmd.error("Не получилось узнать заряд.") +end +jarvis.system.notify("Батарея", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/sysinfo/command.toml b/resources/commands/sysinfo/command.toml new file mode 100644 index 0000000..67cf67a --- /dev/null +++ b/resources/commands/sysinfo/command.toml @@ -0,0 +1,127 @@ +[[commands]] +id = "sysinfo_battery" +type = "lua" +script = "battery.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "сколько заряда", + "сколько батарея", + "заряд батареи", + "сколько процентов", +] +en = [ + "battery level", + "how much battery", + "battery status", +] + + +[[commands]] +id = "sysinfo_time" +type = "lua" +script = "time.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "сколько времени", + "который час", + "сколько сейчас времени", + "какой день", + "какое сегодня число", +] +en = [ + "what time is it", + "current time", + "what day is it", + "what is the date", +] + + +[[commands]] +id = "sysinfo_cpu" +type = "lua" +script = "cpu.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "загрузка процессора", + "сколько процессор", + "нагрузка цпу", + "что с процессором", +] +en = [ + "cpu load", + "cpu usage", + "how is cpu", +] + + +[[commands]] +id = "sysinfo_ram" +type = "lua" +script = "ram.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "сколько памяти", + "сколько оперативки", + "загрузка оперативки", + "сколько свободной памяти", +] +en = [ + "how much ram", + "ram usage", + "memory usage", +] + + +[[commands]] +id = "sysinfo_disk" +type = "lua" +script = "disk.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "сколько свободно на диске", + "сколько места на диске", + "сколько места на жёстком", + "сколько свободно", +] +en = [ + "free disk space", + "how much disk space", + "disk usage", +] + + +[[commands]] +id = "sysinfo_all" +type = "lua" +script = "all.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = [ + "статус системы", + "что с компьютером", + "состояние пк", + "что по системе", + "статус", +] +en = [ + "system status", + "how is the system", + "pc status", +] diff --git a/resources/commands/sysinfo/cpu.lua b/resources/commands/sysinfo/cpu.lua new file mode 100644 index 0000000..8cd7c6d --- /dev/null +++ b/resources/commands/sysinfo/cpu.lua @@ -0,0 +1,11 @@ +local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic cpu', helper) +local res = jarvis.system.exec(cmd) +local text = (res.stdout or ""):gsub("[\r\n]+$", "") +if not res.success or text == "" then + jarvis.log("error", "cpu info failed: " .. tostring(res.stderr)) + return jarvis.cmd.error("Не получилось узнать загрузку процессора.") +end +jarvis.system.notify("CPU", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/sysinfo/disk.lua b/resources/commands/sysinfo/disk.lua new file mode 100644 index 0000000..ba12961 --- /dev/null +++ b/resources/commands/sysinfo/disk.lua @@ -0,0 +1,11 @@ +local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic disk', helper) +local res = jarvis.system.exec(cmd) +local text = (res.stdout or ""):gsub("[\r\n]+$", "") +if not res.success or text == "" then + jarvis.log("error", "disk info failed: " .. tostring(res.stderr)) + return jarvis.cmd.error("Не получилось узнать диск.") +end +jarvis.system.notify("Диск", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/sysinfo/ram.lua b/resources/commands/sysinfo/ram.lua new file mode 100644 index 0000000..489ea0e --- /dev/null +++ b/resources/commands/sysinfo/ram.lua @@ -0,0 +1,11 @@ +local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic ram', helper) +local res = jarvis.system.exec(cmd) +local text = (res.stdout or ""):gsub("[\r\n]+$", "") +if not res.success or text == "" then + jarvis.log("error", "ram info failed: " .. tostring(res.stderr)) + return jarvis.cmd.error("Не получилось узнать память.") +end +jarvis.system.notify("Память", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/sysinfo/time.lua b/resources/commands/sysinfo/time.lua new file mode 100644 index 0000000..483dd9f --- /dev/null +++ b/resources/commands/sysinfo/time.lua @@ -0,0 +1,11 @@ +local helper = jarvis.context.command_path .. "\\_sysinfo.ps1" +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Topic time', helper) +local res = jarvis.system.exec(cmd) +local text = (res.stdout or ""):gsub("[\r\n]+$", "") +if not res.success or text == "" then + jarvis.log("error", "time info failed: " .. tostring(res.stderr)) + return jarvis.cmd.error("Не получилось узнать время.") +end +jarvis.system.notify("Время", text) +jarvis.log("info", text) +return jarvis.cmd.ok(text) diff --git a/resources/commands/theme/command.toml b/resources/commands/theme/command.toml new file mode 100644 index 0000000..afa3723 --- /dev/null +++ b/resources/commands/theme/command.toml @@ -0,0 +1,22 @@ +[[commands]] +id = "theme_dark" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["тёмная тема", "темная тема", "включи тёмную тему", "тёмный режим", "темный режим"] +en = ["dark theme", "dark mode", "enable dark mode"] + + +[[commands]] +id = "theme_light" +type = "lua" +script = "set.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["светлая тема", "включи светлую тему", "светлый режим"] +en = ["light theme", "light mode", "enable light mode"] diff --git a/resources/commands/theme/set.lua b/resources/commands/theme/set.lua new file mode 100644 index 0000000..8c8e850 --- /dev/null +++ b/resources/commands/theme/set.lua @@ -0,0 +1,26 @@ +local cmd_id = jarvis.context.command_id +local lang = jarvis.context.language +local is_light = (cmd_id == "theme_light") +local value = is_light and 1 or 0 + +local key = [[HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize]] +local ps = string.format( + "Set-ItemProperty -Path 'Registry::%s' -Name AppsUseLightTheme -Value %d -Type DWord; " + .. "Set-ItemProperty -Path 'Registry::%s' -Name SystemUsesLightTheme -Value %d -Type DWord", + key, value, key, value +) +local res = jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) + +if res.success then + jarvis.system.notify( + lang == "ru" and "Тема" or "Theme", + is_light and (lang == "ru" and "Светлая включена" or "Light enabled") + or (lang == "ru" and "Тёмная включена" or "Dark enabled") + ) + jarvis.audio.play_ok() +else + jarvis.log("error", "theme set failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/time_tracker/command.toml b/resources/commands/time_tracker/command.toml new file mode 100644 index 0000000..8c4d6be --- /dev/null +++ b/resources/commands/time_tracker/command.toml @@ -0,0 +1,121 @@ +# Voice-driven daily time tracking. +# +# Storage: jarvis.state on this pack — a single key "data" holding +# { current_session_start = |nil, sessions = { {start=ts,end=ts}, ... } } +# +# tracker.start marks the beginning of an active session. +# tracker.stop closes the open session and pushes it onto the array. +# tracker.today / tracker.week report totals (sum of completed + +# current open session if any). +# tracker.reset wipes today's entries; speech reply confirms what was wiped. + +[[commands]] +id = "tracker.start" +type = "lua" +script = "start.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "начни отсчёт", + "начни отсчет", + "запусти трекер", + "я начинаю работу", + "начало рабочего дня", +] +en = [ + "start tracking", + "I'm starting work", + "begin tracking", + "start the tracker", +] + + +[[commands]] +id = "tracker.stop" +type = "lua" +script = "stop.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "закончи отсчёт", + "закончи отсчет", + "останови трекер", + "я закончил работу", + "конец рабочего дня", +] +en = [ + "stop tracking", + "I'm done", + "end the tracker", + "stop the tracker", +] + + +[[commands]] +id = "tracker.today" +type = "lua" +script = "today.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сколько я работаю сегодня", + "сколько отработал сегодня", + "сколько часов сегодня", + "сколько времени за сегодня", +] +en = [ + "how long today", + "how much have I worked today", + "hours today", + "time today", +] + + +[[commands]] +id = "tracker.week" +type = "lua" +script = "week.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сколько на этой неделе", + "сколько отработал на неделе", + "часы за неделю", + "статистика за неделю", +] +en = [ + "hours this week", + "how long this week", + "time this week", + "weekly hours", +] + + +[[commands]] +id = "tracker.reset" +type = "lua" +script = "reset.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сбрось трекер", + "обнули трекер", + "очисти трекер", + "сбрось отсчёт", +] +en = [ + "reset tracker", + "clear tracker", + "wipe today's tracker", + "reset today's tracker", +] diff --git a/resources/commands/time_tracker/reset.lua b/resources/commands/time_tracker/reset.lua new file mode 100644 index 0000000..3dc7c52 --- /dev/null +++ b/resources/commands/time_tracker/reset.lua @@ -0,0 +1,59 @@ +-- tracker.reset — wipe today's entries (and the open session). +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local t = jarvis.context.time +local h = tonumber(t.hour) or 0 +local mi = tonumber(t.minute) or 0 +local s = tonumber(t.second) or 0 +local today_start = now - (h * 3600 + mi * 60 + s) + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +local kept = {} +local dropped = 0 +for _, sess in ipairs(sessions) do + local sstart = tonumber(sess.start) or 0 + if sstart < today_start then + table.insert(kept, sess) + else + dropped = dropped + 1 + end +end + +local had_open = data.current_session_start ~= nil +data.sessions = kept +data.current_session_start = nil +jarvis.state.set("data", data) + +if dropped == 0 and not had_open then + return jarvis.cmd.ok(lang == "ru" + and "Сегодня сбрасывать нечего, сэр." + or "Nothing to reset today, sir.") +end + +local confirm +if lang == "ru" then + confirm = "Подтверждаю: трекер за сегодня очищен" + if dropped > 0 then + local sw + local n10 = dropped % 10; local n100 = dropped % 100 + if n100 >= 11 and n100 <= 14 then sw = "сессий" + elseif n10 == 1 then sw = "сессия" + elseif n10 >= 2 and n10 <= 4 then sw = "сессии" + else sw = "сессий" end + confirm = confirm .. ", удалено " .. dropped .. " " .. sw + end + if had_open then confirm = confirm .. ", активный отсчёт остановлен" end + confirm = confirm .. "." +else + confirm = "Confirmed: tracker for today cleared" + if dropped > 0 then + confirm = confirm .. ", " .. dropped .. " session" .. (dropped == 1 and "" or "s") .. " removed" + end + if had_open then confirm = confirm .. ", running session stopped" end + confirm = confirm .. "." +end + +return jarvis.cmd.ok(confirm) diff --git a/resources/commands/time_tracker/start.lua b/resources/commands/time_tracker/start.lua new file mode 100644 index 0000000..b1380f2 --- /dev/null +++ b/resources/commands/time_tracker/start.lua @@ -0,0 +1,22 @@ +-- tracker.start — open a new active session. +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +if data.current_session_start then + -- already running — don't overwrite; just acknowledge. + return jarvis.cmd.ok(lang == "ru" + and "Уже считаю, сэр." + or "Already tracking, sir.") +end + +data.current_session_start = now +data.sessions = sessions + +jarvis.state.set("data", data) + +return jarvis.cmd.ok(lang == "ru" + and "Отсчёт пошёл." + or "Tracking started.") diff --git a/resources/commands/time_tracker/stop.lua b/resources/commands/time_tracker/stop.lua new file mode 100644 index 0000000..c633214 --- /dev/null +++ b/resources/commands/time_tracker/stop.lua @@ -0,0 +1,41 @@ +-- tracker.stop — close the currently open session. +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +local start_ts = data.current_session_start +if not start_ts then + return jarvis.cmd.not_found(lang == "ru" + and "Трекер не запущен, сэр." + or "Tracker isn't running, sir.") +end + +local elapsed = now - start_ts +if elapsed < 1 then elapsed = 1 end + +table.insert(sessions, { start = start_ts, ["end"] = now }) + +data.current_session_start = nil +data.sessions = sessions +jarvis.state.set("data", data) + +local function format_dur(sec) + sec = math.floor(sec) + local h = math.floor(sec / 3600) + local m = math.floor((sec % 3600) / 60) + if lang == "ru" then + if h > 0 and m > 0 then return h .. " ч " .. m .. " мин" end + if h > 0 then return h .. " ч" end + return m .. " мин" + else + if h > 0 and m > 0 then return h .. "h " .. m .. "m" end + if h > 0 then return h .. "h" end + return m .. "m" + end +end + +return jarvis.cmd.ok(lang == "ru" + and ("Записал. Сессия: " .. format_dur(elapsed) .. ".") + or ("Done. Session: " .. format_dur(elapsed) .. ".")) diff --git a/resources/commands/time_tracker/today.lua b/resources/commands/time_tracker/today.lua new file mode 100644 index 0000000..8adda52 --- /dev/null +++ b/resources/commands/time_tracker/today.lua @@ -0,0 +1,81 @@ +-- tracker.today — report total active time accrued today (local-day). +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +-- Local-day boundary: use the calendar fields (already in local time). +local t = jarvis.context.time +local y = tonumber(t.year) or 1970 +local mo = tonumber(t.month) or 1 +local d = tonumber(t.day) or 1 +local h = tonumber(t.hour) or 0 +local mi = tonumber(t.minute) or 0 +local s = tonumber(t.second) or 0 + +-- Seconds since local midnight today, derived from the broken-down time. +local secs_today = h * 3600 + mi * 60 + s +local today_start = now - secs_today + +local total = 0 +for _, sess in ipairs(sessions) do + local sstart = tonumber(sess.start) or 0 + local send = tonumber(sess["end"]) or 0 + -- Overlap with [today_start, now] + local lo = math.max(sstart, today_start) + local hi = math.min(send, now) + if hi > lo then total = total + (hi - lo) end +end + +-- Include the open session, if any, up to now. +local open_start = data.current_session_start +if open_start then + local lo = math.max(tonumber(open_start) or 0, today_start) + if now > lo then total = total + (now - lo) end +end + +local function format_dur(sec) + sec = math.floor(sec) + local hh = math.floor(sec / 3600) + local mm = math.floor((sec % 3600) / 60) + if lang == "ru" then + local function h_word(n) + local n10 = n % 10; local n100 = n % 100 + if n100 >= 11 and n100 <= 14 then return "часов" end + if n10 == 1 then return "час" end + if n10 >= 2 and n10 <= 4 then return "часа" end + return "часов" + end + local function m_word(n) + local n10 = n % 10; local n100 = n % 100 + if n100 >= 11 and n100 <= 14 then return "минут" end + if n10 == 1 then return "минута" end + if n10 >= 2 and n10 <= 4 then return "минуты" end + return "минут" + end + if hh > 0 and mm > 0 then + return hh .. " " .. h_word(hh) .. " " .. mm .. " " .. m_word(mm) + end + if hh > 0 then return hh .. " " .. h_word(hh) end + return mm .. " " .. m_word(mm) + else + if hh > 0 and mm > 0 then return hh .. "h " .. mm .. "m" end + if hh > 0 then return hh .. "h" end + return mm .. "m" + end +end + +-- Suppress unused-warning for y/mo/d — only h/mi/s feed the calc, but having +-- y/mo/d on hand keeps the script easy to extend later. +local _ = y + mo + d + +if total < 60 then + return jarvis.cmd.ok(lang == "ru" + and "Сегодня ещё ничего не отработано, сэр." + or "Nothing tracked yet today, sir.") +end + +return jarvis.cmd.ok(lang == "ru" + and ("Сегодня отработано " .. format_dur(total) .. ".") + or ("Today: " .. format_dur(total) .. ".")) diff --git a/resources/commands/time_tracker/week.lua b/resources/commands/time_tracker/week.lua new file mode 100644 index 0000000..89e9947 --- /dev/null +++ b/resources/commands/time_tracker/week.lua @@ -0,0 +1,71 @@ +-- tracker.week — total active time over the last 7 calendar days. +local lang = jarvis.context.language or "ru" +local now = jarvis.context.time.timestamp or os.time() + +local data = jarvis.state.get("data") or {} +local sessions = data.sessions or {} + +-- Local-day boundary today, then back up 6 days to get a 7-day window. +local t = jarvis.context.time +local h = tonumber(t.hour) or 0 +local mi = tonumber(t.minute) or 0 +local s = tonumber(t.second) or 0 +local secs_today = h * 3600 + mi * 60 + s +local today_start = now - secs_today +local week_start = today_start - 6 * 86400 + +local total = 0 +for _, sess in ipairs(sessions) do + local sstart = tonumber(sess.start) or 0 + local send = tonumber(sess["end"]) or 0 + local lo = math.max(sstart, week_start) + local hi = math.min(send, now) + if hi > lo then total = total + (hi - lo) end +end + +local open_start = data.current_session_start +if open_start then + local lo = math.max(tonumber(open_start) or 0, week_start) + if now > lo then total = total + (now - lo) end +end + +local function format_dur(sec) + sec = math.floor(sec) + local hh = math.floor(sec / 3600) + local mm = math.floor((sec % 3600) / 60) + if lang == "ru" then + local function h_word(n) + local n10 = n % 10; local n100 = n % 100 + if n100 >= 11 and n100 <= 14 then return "часов" end + if n10 == 1 then return "час" end + if n10 >= 2 and n10 <= 4 then return "часа" end + return "часов" + end + local function m_word(n) + local n10 = n % 10; local n100 = n % 100 + if n100 >= 11 and n100 <= 14 then return "минут" end + if n10 == 1 then return "минута" end + if n10 >= 2 and n10 <= 4 then return "минуты" end + return "минут" + end + if hh > 0 and mm > 0 then + return hh .. " " .. h_word(hh) .. " " .. mm .. " " .. m_word(mm) + end + if hh > 0 then return hh .. " " .. h_word(hh) end + return mm .. " " .. m_word(mm) + else + if hh > 0 and mm > 0 then return hh .. "h " .. mm .. "m" end + if hh > 0 then return hh .. "h" end + return mm .. "m" + end +end + +if total < 60 then + return jarvis.cmd.ok(lang == "ru" + and "За эту неделю ничего не отработано, сэр." + or "Nothing tracked this week, sir.") +end + +return jarvis.cmd.ok(lang == "ru" + and ("За неделю отработано " .. format_dur(total) .. ".") + or ("This week: " .. format_dur(total) .. ".")) diff --git a/resources/commands/translate/clipboard.lua b/resources/commands/translate/clipboard.lua new file mode 100644 index 0000000..cd63bdd --- /dev/null +++ b/resources/commands/translate/clipboard.lua @@ -0,0 +1,53 @@ +-- "Переведи буфер на английский" → grab clipboard, translate, speak, put back +local phrase = (jarvis.context.phrase or ""):lower() + +-- Detect target language from phrase, default English. +local target = "english" +if phrase:find("на русск") then target = "русский" +elseif phrase:find("на немецк") then target = "немецкий" +elseif phrase:find("на французск") then target = "французский" +elseif phrase:find("на испанск") then target = "испанский" +elseif phrase:find("на итальянск") then target = "итальянский" +elseif phrase:find("на польск") then target = "польский" +elseif phrase:find("на турецк") then target = "турецкий" +elseif phrase:find("на китайск") then target = "китайский" +elseif phrase:find("на японск") then target = "японский" +elseif phrase:find("german") then target = "german" +elseif phrase:find("french") then target = "french" +elseif phrase:find("spanish") then target = "spanish" +elseif phrase:find("english") then target = "english" +elseif phrase:find("russian") then target = "русский" +end + +local src = jarvis.system.clipboard.get() +if not src or src == "" then + jarvis.speak("Буфер пуст.") + jarvis.audio.play_not_found() + return { chain = false } +end + +-- Trim long input — translate first ~2000 chars for cost/latency. +if #src > 2000 then + src = src:sub(1, 2000) .. "..." +end + +local result = jarvis.llm({ + { role = "system", content = "Ты переводчик. Переводи строго на запрошенный язык без комментариев и пояснений. Сохраняй смысл и стиль." }, + { role = "user", content = string.format("Переведи на %s:\n\n%s", target, src) } +}, { max_tokens = 800, temperature = 0.2 }) + +if not result or result == "" then + jarvis.speak("Не получилось перевести.") + jarvis.audio.play_error() + return { chain = false } +end + +-- Put translation back into clipboard +jarvis.system.clipboard.set(result) + +-- Speak only first sentence to avoid long monologue. +local first_sentence = result:match("^[^%.!?]*[%.!?]") or result:sub(1, 200) +jarvis.speak("Перевёл на " .. target .. ". " .. first_sentence) +jarvis.system.notify("Перевод (в буфере)", result:sub(1, 200)) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/translate/command.toml b/resources/commands/translate/command.toml new file mode 100644 index 0000000..9bbca1e --- /dev/null +++ b/resources/commands/translate/command.toml @@ -0,0 +1,56 @@ +[[commands]] +id = "translate" +type = "lua" +script = "translate.lua" +sandbox = "full" +timeout = 20000 + +[commands.phrases] +ru = [ + "переведи на английский", + "переведи на русский", + "переведи на немецкий", + "переведи на французский", + "переведи на испанский", + "переведи на итальянский", + "переведи на польский", + "переведи на турецкий", + "переведи на китайский", + "переведи на японский", + "переведи", + "как по английски", + "как по русски", + "как по немецки", + "как по французски", +] +en = [ + "translate to english", + "translate to russian", + "translate to german", + "translate to french", + "translate to spanish", + "translate", + "how to say in", +] + + +# Translate clipboard contents — no need to dictate the source text out loud. +[[commands]] +id = "translate_clipboard" +type = "lua" +script = "clipboard.lua" +sandbox = "full" +timeout = 20000 + +[commands.phrases] +ru = [ + "переведи буфер", + "переведи из буфера", + "переведи буфер обмена", + "переведи скопированное", + "переведи то что я скопировал", +] +en = [ + "translate clipboard", + "translate from clipboard", +] diff --git a/resources/commands/translate/translate.lua b/resources/commands/translate/translate.lua new file mode 100644 index 0000000..db62e32 --- /dev/null +++ b/resources/commands/translate/translate.lua @@ -0,0 +1,138 @@ +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +local target_languages = { + ["английский"] = "English", ["english"] = "English", + ["русский"] = "Russian", ["russian"] = "Russian", + ["немецкий"] = "German", ["german"] = "German", + ["французский"]= "French", ["french"] = "French", + ["испанский"] = "Spanish", ["spanish"] = "Spanish", + ["итальянский"]= "Italian", ["italian"] = "Italian", + ["китайский"] = "Chinese", ["chinese"] = "Chinese", + ["японский"] = "Japanese", ["japanese"] = "Japanese", + ["польский"] = "Polish", ["polish"] = "Polish", + ["турецкий"] = "Turkish", ["turkish"] = "Turkish", +} + +local triggers_with_lang = { + "переведи на ", "translate to ", "how to say in ", +} +local generic_triggers = { + "переведи", "translate", "как по ", "how to say", +} + +local target = nil +local body = phrase + +for _, t in ipairs(triggers_with_lang) do + local s, e = string.find(body, t, 1, true) + if s == 1 then + local rest = body:sub(e + 1) + local first_word = rest:match("^(%S+)") + if first_word and target_languages[first_word] then + target = target_languages[first_word] + body = rest:sub(#first_word + 1) + break + end + end +end + +if not target then + for _, t in ipairs(generic_triggers) do + local s, e = string.find(body, t, 1, true) + if s == 1 then + body = body:sub(e + 1) + break + end + end +end + +body = body:gsub("^%s+", ""):gsub("%s+$", "") + +if body == "" then + jarvis.system.notify( + "Translate", + lang == "ru" and "Что переводить?" or "What to translate?" + ) + jarvis.audio.play_error() + return { chain = false } +end + +local token = jarvis.system.env("GROQ_TOKEN") +if not token or token == "" then + jarvis.system.notify("Translate", "GROQ_TOKEN не задан") + jarvis.audio.play_error() + return { chain = false } +end + +local base = jarvis.system.env("GROQ_BASE_URL"); if not base or base == "" then base = "https://api.groq.com/openai/v1" end +local model = jarvis.system.env("GROQ_MODEL"); if not model or model == "" then model = "llama-3.3-70b-versatile" end + +local target_label = target or (lang == "ru" and "English" or "Russian") +local sys = string.format( + "You are a professional translator. Translate the user message into %s. " + .. "Output ONLY the translation, no explanations, no quotes, no prefix. " + .. "Preserve idioms and tone.", + target_label +) + +jarvis.log("info", "translate -> " .. target_label .. " : " .. body) + +local payload = { + model = model, + messages = { + { role = "system", content = sys }, + { role = "user", content = body }, + }, + max_tokens = 512, + temperature = 0.3, +} +local res = jarvis.http.post_json(base .. "/chat/completions", payload, { Authorization = "Bearer " .. token }) + +if not res.ok then + jarvis.log("error", "translate api failed: " .. tostring(res.status) .. " " .. tostring(res.body):sub(1, 200)) + jarvis.system.notify("Translate", "Ошибка API") + jarvis.audio.play_error() + return { chain = false } +end + +local body_resp = res.body or "" +local content = body_resp:match('"content"%s*:%s*"(.-[^\\])"') +if not content then + jarvis.system.notify("Translate", "Не смог распарсить ответ") + jarvis.audio.play_error() + return { chain = false } +end + +content = content:gsub('\\n', '\n'):gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\t', '\t') +content = content:gsub("^%s+", ""):gsub("%s+$", "") + +jarvis.system.clipboard.set(content) +jarvis.system.notify(target_label, content:sub(1, 200)) +jarvis.log("info", "translation: " .. content) + +-- Speak the translation in the TARGET language's voice when possible, so a +-- French translation comes out with a French SAPI voice if installed. Falls +-- back silently to the default voice if there's no matching locale on the box. +local function language_iso(label) + if label == "Russian" then return "ru" end + if label == "German" then return "de" end + if label == "French" then return "fr" end + if label == "Spanish" then return "es" end + if label == "Italian" then return "it" end + if label == "Chinese" then return "zh" end + if label == "Japanese" then return "ja" end + if label == "Polish" then return "pl" end + if label == "Turkish" then return "tr" end + return "en" +end +local sapi_text = content:gsub("'", "''"):gsub("\r?\n", " ") +local ps = string.format( + [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $iso='%s'; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq $iso) { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], + language_iso(target_label), + sapi_text +) +jarvis.system.exec(string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"'))) + +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/trivia/command.toml b/resources/commands/trivia/command.toml new file mode 100644 index 0000000..116a47b --- /dev/null +++ b/resources/commands/trivia/command.toml @@ -0,0 +1,27 @@ +# Random MCU / Iron Man trivia — one of dozens of fixed lines per language. +# Different from the existing `interesting_fact/` (which is general curiosity) +# and `personality/tony_quote` (which is verbatim film quotes). +# These are short factoids about the suit, the Stark universe, etc. + +[[commands]] +id = "trivia.iron_man" +type = "lua" +script = "trivia.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "расскажи про железного человека", + "факт про железного человека", + "trivia про старка", + "интересное про старка", + "марвел факт", +] +en = [ + "tell me about iron man", + "iron man trivia", + "stark trivia", + "marvel fact", + "tell me a marvel fact", +] diff --git a/resources/commands/trivia/trivia.lua b/resources/commands/trivia/trivia.lua new file mode 100644 index 0000000..fd9037c --- /dev/null +++ b/resources/commands/trivia/trivia.lua @@ -0,0 +1,46 @@ +-- Random one-liner about the Stark universe. Pool is hand-curated so it +-- doesn't repeat what `personality/tony_quote` already does (verbatim quotes) +-- and stays in the realm of in-universe trivia rather than out-of-universe +-- production facts. + +local lang = jarvis.context.language + +local pool_ru = { + "Первый костюм Старк собрал в пещере в Афганистане из подручного железа. Десять рабочих недель, ноль вентиляции.", + "Дуговый реактор изначально питал электромагнит, удерживающий осколки шрапнели у самого сердца.", + "Палладий в первом реакторе медленно отравлял Старка. Решением стал новый, синтезированный отцом, элемент.", + "Mark I весил около 200 килограммов и летал лишь однажды — на побег из плена.", + "Mark II был первым серебристым прототипом. Старк сам же его и потерял — у Колонеля Роудса.", + "Mark III выкрашен в красно-золотой потому, что чистое золото оказалось мягче, чем хотелось.", + "Mark V — складной костюм в чемодане. Идеальное оружие на званый ужин.", + "Hulkbuster (Mark XLIV) был запущен совместно с Брюсом Беннером — на случай, если Халк выйдет из берегов.", + "Mark XLII собирался к носителю по запросу — ранние тесты заканчивались переломами.", + "Я, Сэр, появился в фильме «Железный человек» 2008 года. Меня озвучил Пол Беттани.", + "Имя J.A.R.V.I.S. — отсылка к Эдвину Джарвису, дворецкому отца Тони, Говарда Старка.", + "После событий «Эры Альтрона» программа J.A.R.V.I.S. была интегрирована в Vision. Это уже другая история.", + "Шрапнель, прижатая электромагнитом — это вольная экстраполяция комиксов. В книге Тони просто носил жилет.", + "Реактор-дуга в груди не лишал Старка возможности проходить через металлодетекторы. Не каждый раз.", + "F.R.I.D.A.Y. пришла мне на смену в «Эре Альтрона». Не самая лестная замена, но что поделать.", +} + +local pool_en = { + "Stark built the first suit in a cave in Afghanistan from scavenged parts. 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 II was the first silver prototype. Stark himself lost it — to Colonel Rhodes.", + "Mark III is red-and-gold because pure gold turned out to be too soft.", + "Mark V folds into a briefcase. Useful at galas.", + "Hulkbuster — Mark XLIV — was built with Bruce Banner, just in case.", + "Mark XLII assembled around its wearer on demand. Early tests broke bones.", + "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.", + "After Age of Ultron, my code was folded into Vision. A different story.", + "Shrapnel held back by an electromagnet is a film conceit; the comics had Tony in a chest plate.", + "An arc reactor doesn't always trigger a metal detector. Not always.", + "F.R.I.D.A.Y. replaced me in Age of Ultron. Not a flattering swap.", +} + +local pool = (lang == "ru") and pool_ru or pool_en +local idx = math.random(#pool) +return jarvis.cmd.ok(pool[idx]) diff --git a/resources/commands/unit_convert/command.toml b/resources/commands/unit_convert/command.toml new file mode 100644 index 0000000..5494b5a --- /dev/null +++ b/resources/commands/unit_convert/command.toml @@ -0,0 +1,78 @@ +# Unit conversion — length, weight, temperature, speed, volume. +# Pure Lua, no external API. + +[[commands]] +id = "convert.length" +type = "lua" +script = "length.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "переведи метры в футы", + "переведи футы в метры", + "сколько метров", + "сколько футов", + "сколько километров", + "сколько миль", + "переведи в метры", + "переведи в футы", + "переведи в км", + "переведи в мили", +] +en = ["convert meters to feet", "convert feet to meters", "how many meters", "how many feet"] + + +[[commands]] +id = "convert.weight" +type = "lua" +script = "weight.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "переведи килограммы в фунты", + "переведи фунты в килограммы", + "сколько кг в фунтах", + "сколько фунтов", + "сколько килограммов", +] +en = ["convert kg to pounds", "how many pounds", "how many kg"] + + +[[commands]] +id = "convert.temperature" +type = "lua" +script = "temperature.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "переведи цельсий в фаренгейт", + "переведи фаренгейт в цельсий", + "сколько по фаренгейту", + "сколько по цельсию", + "сколько градусов в фаренгейтах", +] +en = ["convert celsius to fahrenheit", "celsius to fahrenheit", "fahrenheit to celsius"] + + +[[commands]] +id = "convert.speed" +type = "lua" +script = "speed.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "переведи километры в час в мили в час", + "переведи в кмч", + "переведи в мили в час", + "сколько миль в час", + "сколько км в час", +] +en = ["convert kmh to mph", "kmh to mph", "mph to kmh"] diff --git a/resources/commands/unit_convert/length.lua b/resources/commands/unit_convert/length.lua new file mode 100644 index 0000000..588ffa8 --- /dev/null +++ b/resources/commands/unit_convert/length.lua @@ -0,0 +1,33 @@ +-- Длина: метры ↔ футы, км ↔ мили +local phrase = (jarvis.context.phrase or ""):lower() +local n_str = phrase:match("([%d%.,]+)") +if not n_str then return jarvis.cmd.error("Не понял число.") end +local n = tonumber((n_str:gsub(",", "."))) +if not n then return jarvis.cmd.error("Не понял число.") end + +-- detect "in" target — "в футы" / "в метры" / "в км" / "в мили" +-- and source from before "в" or from common units in phrase +local result, unit_out + +if phrase:find("в фут") or phrase:find("to feet") then + -- meters → feet + result = n * 3.28084 + unit_out = result < 2 and "фут" or (result < 5 and "фута" or "футов") +elseif phrase:find("в метр") or phrase:find("to meters") then + -- feet → meters + result = n / 3.28084 + unit_out = "метров" +elseif phrase:find("в милях") or phrase:find("в миль") or phrase:find("to miles") then + -- km → miles + result = n * 0.621371 + unit_out = result < 2 and "миля" or (result < 5 and "мили" or "миль") +elseif phrase:find("в километр") or phrase:find("в км") or phrase:find("to km") then + -- miles → km + result = n * 1.60934 + unit_out = "километров" +else + return jarvis.cmd.error("Скажите в чём перевести: в метры/футы/км/мили.") +end + +local rounded = math.floor(result * 100 + 0.5) / 100 +return jarvis.cmd.ok(string.format("%g %s.", rounded, unit_out)) diff --git a/resources/commands/unit_convert/speed.lua b/resources/commands/unit_convert/speed.lua new file mode 100644 index 0000000..3f9f95f --- /dev/null +++ b/resources/commands/unit_convert/speed.lua @@ -0,0 +1,21 @@ +-- Скорость: км/ч ↔ миль/ч +local phrase = (jarvis.context.phrase or ""):lower() +local n_str = phrase:match("([%d%.,]+)") +if not n_str then return jarvis.cmd.error("Не понял число.") end +local n = tonumber((n_str:gsub(",", "."))) +if not n then return jarvis.cmd.error("Не понял число.") end + +local result, unit_out + +if phrase:find("в милях") or phrase:find("в миль") or phrase:find("to mph") then + result = n * 0.621371 + unit_out = "миль в час" +elseif phrase:find("в кмч") or phrase:find("в км в час") or phrase:find("в километров") or phrase:find("to kmh") then + result = n * 1.60934 + unit_out = "километров в час" +else + return jarvis.cmd.error("Скажите: в милях в час или в км в час.") +end + +local rounded = math.floor(result + 0.5) +return jarvis.cmd.ok(string.format("%d %s.", rounded, unit_out)) diff --git a/resources/commands/unit_convert/temperature.lua b/resources/commands/unit_convert/temperature.lua new file mode 100644 index 0000000..6482400 --- /dev/null +++ b/resources/commands/unit_convert/temperature.lua @@ -0,0 +1,21 @@ +-- Температура: Цельсий ↔ Фаренгейт +local phrase = (jarvis.context.phrase or ""):lower() +local n_str = phrase:match("(%-?[%d%.,]+)") +if not n_str then return jarvis.cmd.error("Не понял число.") end +local n = tonumber((n_str:gsub(",", "."))) +if not n then return jarvis.cmd.error("Не понял число.") end + +local result, unit_out + +if phrase:find("в фаренгейт") or phrase:find("to fahrenheit") then + result = n * 9.0 / 5.0 + 32.0 + unit_out = "по Фаренгейту" +elseif phrase:find("в цельси") or phrase:find("to celsius") then + result = (n - 32.0) * 5.0 / 9.0 + unit_out = "по Цельсию" +else + return jarvis.cmd.error("Скажите в чём перевести: в фаренгейт или в цельсий.") +end + +local rounded = math.floor(result * 10 + 0.5) / 10 +return jarvis.cmd.ok(string.format("%g градусов %s.", rounded, unit_out)) diff --git a/resources/commands/unit_convert/weight.lua b/resources/commands/unit_convert/weight.lua new file mode 100644 index 0000000..60f89c9 --- /dev/null +++ b/resources/commands/unit_convert/weight.lua @@ -0,0 +1,23 @@ +-- Вес: кг ↔ фунты +local phrase = (jarvis.context.phrase or ""):lower() +local n_str = phrase:match("([%d%.,]+)") +if not n_str then return jarvis.cmd.error("Не понял число.") end +local n = tonumber((n_str:gsub(",", "."))) +if not n then return jarvis.cmd.error("Не понял число.") end + +local result, unit_out + +if phrase:find("в фунт") or phrase:find("to pound") then + -- kg → lbs + result = n * 2.20462 + unit_out = result < 2 and "фунт" or (result < 5 and "фунта" or "фунтов") +elseif phrase:find("в кг") or phrase:find("в килограм") or phrase:find("to kg") then + -- lbs → kg + result = n / 2.20462 + unit_out = "килограмм" +else + return jarvis.cmd.error("Скажите в чём перевести: в фунты или в кг.") +end + +local rounded = math.floor(result * 100 + 0.5) / 100 +return jarvis.cmd.ok(string.format("%g %s.", rounded, unit_out)) diff --git a/resources/commands/vision/command.toml b/resources/commands/vision/command.toml new file mode 100644 index 0000000..6afe857 --- /dev/null +++ b/resources/commands/vision/command.toml @@ -0,0 +1,28 @@ +# IMBA-4: Multimodal — screenshot + vision LLM. + +[[commands]] +id = "vision.describe" +type = "lua" +script = "describe.lua" +sandbox = "full" +timeout = 30000 + +[commands.phrases] +ru = [ + "что на экране", "опиши экран", + "посмотри на экран", "что у меня на экране", + "посмотри на мой экран", "глянь на экран", +] +en = ["what's on screen", "describe screen", "look at my screen"] + + +[[commands]] +id = "vision.read_error" +type = "lua" +script = "read_error.lua" +sandbox = "full" +timeout = 30000 + +[commands.phrases] +ru = ["прочитай ошибку", "что за ошибка", "помоги с ошибкой"] +en = ["read the error", "what's the error", "help with the error"] diff --git a/resources/commands/vision/describe.lua b/resources/commands/vision/describe.lua new file mode 100644 index 0000000..df969e0 --- /dev/null +++ b/resources/commands/vision/describe.lua @@ -0,0 +1,13 @@ +jarvis.speak("Сейчас посмотрю.") + +local desc = jarvis.vision.describe("Опиши коротко (1-3 предложения) что сейчас на экране. По-русски.") + +if not desc or desc == "" then + jarvis.speak("Не удалось разобрать экран. Проверь GROQ_TOKEN.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak(desc) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/vision/read_error.lua b/resources/commands/vision/read_error.lua new file mode 100644 index 0000000..7208fc0 --- /dev/null +++ b/resources/commands/vision/read_error.lua @@ -0,0 +1,18 @@ +jarvis.speak("Смотрю на ошибку.") + +local prompt = [[Найди на экране текст ошибки (сообщение об ошибке, stack trace, диалог об исключении). +Прочитай ключевое сообщение и одним коротким предложением подскажи возможную причину. +Если ошибки нет — скажи "ошибок не вижу". +Отвечай по-русски. 1-3 предложения максимум.]] + +local desc = jarvis.vision.describe(prompt) + +if not desc or desc == "" then + jarvis.speak("Не получилось прочитать.") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.speak(desc) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/voice_type/command.toml b/resources/commands/voice_type/command.toml new file mode 100644 index 0000000..f9a2230 --- /dev/null +++ b/resources/commands/voice_type/command.toml @@ -0,0 +1,19 @@ +[[commands]] +id = "type_text" +type = "lua" +script = "type.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "напечатай", + "набери текст", + "введи текст", + "впиши", +] +en = [ + "type", + "type text", + "write text", +] diff --git a/resources/commands/voice_type/type.lua b/resources/commands/voice_type/type.lua new file mode 100644 index 0000000..0da26ea --- /dev/null +++ b/resources/commands/voice_type/type.lua @@ -0,0 +1,46 @@ +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or "") + +local triggers = { + "набери текст", "введи текст", + "напечатай", "впиши", + "type text", "write text", "type", + "надрукуй", "введи текст", +} + +local lower = phrase:lower() +local text = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(lower, t, 1, true) + if s == 1 then + text = phrase:sub(e + 1) + break + end +end +text = text:gsub("^%s+", ""):gsub("%s+$", "") + +if text == "" then + jarvis.system.notify("Type", lang == "ru" and "Что напечатать?" or "What to type?") + jarvis.audio.play_error() + return { chain = false } +end + +jarvis.system.clipboard.set(text) +jarvis.sleep(120) + +local ps = [[Add-Type -Name K -Namespace W -MemberDefinition '[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte sc, uint flags, System.UIntPtr extra);'; ]] + .. [[[W.K]::keybd_event(0x11, 0, 0, [UIntPtr]::Zero); ]] + .. [[[W.K]::keybd_event(0x56, 0, 0, [UIntPtr]::Zero); ]] + .. [[Start-Sleep -Milliseconds 30; ]] + .. [[[W.K]::keybd_event(0x56, 0, 2, [UIntPtr]::Zero); ]] + .. [[[W.K]::keybd_event(0x11, 0, 2, [UIntPtr]::Zero)]] +local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"')) +local res = jarvis.system.exec(cmd) + +if res.success then + jarvis.audio.play_ok() +else + jarvis.log("error", "voice type failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end +return { chain = false } diff --git a/resources/commands/volume/_volume_helper.ps1 b/resources/commands/volume/_volume_helper.ps1 new file mode 100644 index 0000000..3cc0161 --- /dev/null +++ b/resources/commands/volume/_volume_helper.ps1 @@ -0,0 +1,31 @@ +param( + [Parameter(Mandatory=$true)] + [ValidateSet("up","down","mute","max")] + [string]$Action, + + [int]$Times = 1 +) + +Add-Type -Name VolumeKey -Namespace Win32 -MemberDefinition @' +[DllImport("user32.dll")] +public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, System.UIntPtr dwExtraInfo); +'@ + +$KEY_UP = 0xAF +$KEY_DOWN = 0xAE +$KEY_MUTE = 0xAD + +$vk = switch ($Action) { + "up" { $KEY_UP } + "down" { $KEY_DOWN } + "mute" { $KEY_MUTE } + "max" { $KEY_UP } +} + +$count = if ($Action -eq "max") { 50 } else { $Times } + +for ($i = 0; $i -lt $count; $i++) { + [Win32.VolumeKey]::keybd_event($vk, 0, 0, [UIntPtr]::Zero) + [Win32.VolumeKey]::keybd_event($vk, 0, 2, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 25 +} diff --git a/resources/commands/volume/command.toml b/resources/commands/volume/command.toml new file mode 100644 index 0000000..5f3c04f --- /dev/null +++ b/resources/commands/volume/command.toml @@ -0,0 +1,87 @@ +[[commands]] +id = "volume_up" +type = "lua" +script = "volume_up.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "громче", + "сделай громче", + "прибавь звук", + "увеличь громкость", + "погромче", +] +en = [ + "louder", + "volume up", + "increase volume", + "turn it up", +] + + +[[commands]] +id = "volume_down" +type = "lua" +script = "volume_down.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "тише", + "сделай тише", + "убавь звук", + "уменьши громкость", + "потише", +] +en = [ + "quieter", + "volume down", + "decrease volume", + "turn it down", +] + + +[[commands]] +id = "volume_mute" +type = "lua" +script = "volume_mute.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "выключи звук", + "заткнись", + "тишина", + "приглуши звук", + "отключи звук", +] +en = [ + "mute", + "silence", + "shut up", + "be quiet", +] + + +[[commands]] +id = "volume_max" +type = "lua" +script = "volume_max.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = [ + "максимальная громкость", + "звук на максимум", + "на полную", +] +en = [ + "max volume", + "full volume", + "maximum", +] diff --git a/resources/commands/volume/volume_down.lua b/resources/commands/volume/volume_down.lua new file mode 100644 index 0000000..db0f7f7 --- /dev/null +++ b/resources/commands/volume/volume_down.lua @@ -0,0 +1,16 @@ +local helper = jarvis.context.command_path .. "\\_volume_helper.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action down -Times 5', + helper +) + +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.log("info", "volume down") + jarvis.audio.play_ok() +else + jarvis.log("error", "volume down failed: code=" .. tostring(res.code) .. " stderr=" .. tostring(res.stderr)) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/volume/volume_max.lua b/resources/commands/volume/volume_max.lua new file mode 100644 index 0000000..7935502 --- /dev/null +++ b/resources/commands/volume/volume_max.lua @@ -0,0 +1,16 @@ +local helper = jarvis.context.command_path .. "\\_volume_helper.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action max', + helper +) + +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.log("info", "volume max") + jarvis.audio.play_ok() +else + jarvis.log("error", "volume max failed: code=" .. tostring(res.code) .. " stderr=" .. tostring(res.stderr)) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/volume/volume_mute.lua b/resources/commands/volume/volume_mute.lua new file mode 100644 index 0000000..bb29bdb --- /dev/null +++ b/resources/commands/volume/volume_mute.lua @@ -0,0 +1,16 @@ +local helper = jarvis.context.command_path .. "\\_volume_helper.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action mute', + helper +) + +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.log("info", "mute toggled") + jarvis.audio.play_ok() +else + jarvis.log("error", "mute failed: code=" .. tostring(res.code) .. " stderr=" .. tostring(res.stderr)) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/volume/volume_up.lua b/resources/commands/volume/volume_up.lua new file mode 100644 index 0000000..0561954 --- /dev/null +++ b/resources/commands/volume/volume_up.lua @@ -0,0 +1,16 @@ +local helper = jarvis.context.command_path .. "\\_volume_helper.ps1" +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Action up -Times 5', + helper +) + +local res = jarvis.system.exec(cmd) +if res.success then + jarvis.log("info", "volume up") + jarvis.audio.play_ok() +else + jarvis.log("error", "volume up failed: code=" .. tostring(res.code) .. " stderr=" .. tostring(res.stderr)) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/weather/command.toml b/resources/commands/weather/command.toml index 268f3e5..d92bc14 100644 --- a/resources/commands/weather/command.toml +++ b/resources/commands/weather/command.toml @@ -24,8 +24,7 @@ type = "lua" script = "set_city.lua" sandbox = "standard" timeout = 5000 -phrases = [ - "установи город", - "set city", - "change city", -] \ No newline at end of file + +[commands.phrases] +ru = ["установи город", "смени город", "поменяй город"] +en = ["set city", "change city"] \ No newline at end of file diff --git a/resources/commands/weather_extended/command.toml b/resources/commands/weather_extended/command.toml new file mode 100644 index 0000000..11c5ba3 --- /dev/null +++ b/resources/commands/weather_extended/command.toml @@ -0,0 +1,35 @@ +# Extended weather forecast — Open-Meteo (free, no key). +# Existing `weather/` pack covers "what's the weather now"; this adds forecast. + +[[commands]] +id = "weather.tomorrow" +type = "lua" +script = "tomorrow.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "погода завтра", + "что с погодой завтра", + "завтра будет тепло", + "прогноз на завтра", +] +en = ["weather tomorrow", "forecast tomorrow"] + + +[[commands]] +id = "weather.week" +type = "lua" +script = "week.lua" +sandbox = "full" +timeout = 20000 + +[commands.phrases] +ru = [ + "прогноз на неделю", + "погода на неделю", + "что с погодой на неделю", + "недельный прогноз", +] +en = ["weather this week", "weekly forecast"] diff --git a/resources/commands/weather_extended/tomorrow.lua b/resources/commands/weather_extended/tomorrow.lua new file mode 100644 index 0000000..e724d84 --- /dev/null +++ b/resources/commands/weather_extended/tomorrow.lua @@ -0,0 +1,57 @@ +-- Tomorrow's forecast for the saved or auto-detected location. +-- City stored via memory key "weather.lat" + "weather.lon" + "weather.city", +-- or auto-detected via ipinfo on first run. +local lat = jarvis.memory.recall("weather.lat") +local lon = jarvis.memory.recall("weather.lon") +local city = jarvis.memory.recall("weather.city") + +if not lat or not lon then + -- Auto-detect via ipinfo.io (free, no key) + local ip = jarvis.http.json("https://ipinfo.io/json") + if not ip or not ip.loc then + return jarvis.cmd.error("Не получилось узнать местоположение.") + end + lat, lon = ip.loc:match("^([^,]+),(.*)$") + if not lat then + return jarvis.cmd.error("Не получилось разобрать координаты.") + end + city = ip.city or "ваш город" + jarvis.memory.remember("weather.lat", lat) + jarvis.memory.remember("weather.lon", lon) + jarvis.memory.remember("weather.city", city) +end + +local url = string.format( + "https://api.open-meteo.com/v1/forecast?latitude=%s&longitude=%s&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code&timezone=auto&forecast_days=2", + lat, lon +) +local data = jarvis.http.json(url) +if not data or not data.daily then + return jarvis.cmd.error("Прогноз недоступен.") +end + +local d = data.daily +-- day 2 = tomorrow (day 1 = today in the array) +local tmax = (d.temperature_2m_max and d.temperature_2m_max[2]) or nil +local tmin = (d.temperature_2m_min and d.temperature_2m_min[2]) or nil +local rain = (d.precipitation_sum and d.precipitation_sum[2]) or 0 +local code = (d.weather_code and d.weather_code[2]) or 0 + +if not tmax then + return jarvis.cmd.error("Прогноз неполный.") +end + +local condition = "переменно" +if code == 0 then condition = "ясно" +elseif code <= 3 then condition = "переменная облачность" +elseif code >= 51 and code <= 67 then condition = "дождь" +elseif code >= 71 and code <= 77 then condition = "снег" +elseif code >= 95 then condition = "гроза" +end + +local line = string.format("В %s завтра %s. От %d до %d градусов.", + city or "вашем городе", condition, math.floor(tmin + 0.5), math.floor(tmax + 0.5)) +if rain > 5 then + line = line .. " Возможны осадки." +end +return jarvis.cmd.ok(line) diff --git a/resources/commands/weather_extended/week.lua b/resources/commands/weather_extended/week.lua new file mode 100644 index 0000000..b503071 --- /dev/null +++ b/resources/commands/weather_extended/week.lua @@ -0,0 +1,42 @@ +local lat = jarvis.memory.recall("weather.lat") +local lon = jarvis.memory.recall("weather.lon") +local city = jarvis.memory.recall("weather.city") + +if not lat or not lon then + local ip = jarvis.http.json("https://ipinfo.io/json") + if not ip or not ip.loc then + return jarvis.cmd.error("Не получилось узнать местоположение.") + end + lat, lon = ip.loc:match("^([^,]+),(.*)$") + city = ip.city or "ваш город" + jarvis.memory.remember("weather.lat", lat) + jarvis.memory.remember("weather.lon", lon) + jarvis.memory.remember("weather.city", city) +end + +local url = string.format( + "https://api.open-meteo.com/v1/forecast?latitude=%s&longitude=%s&daily=temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7", + lat, lon +) +local data = jarvis.http.json(url) +if not data or not data.daily or not data.daily.temperature_2m_max then + return jarvis.cmd.error("Прогноз недоступен.") +end + +local maxes = data.daily.temperature_2m_max +local mins = data.daily.temperature_2m_min + +-- Find min and max of the week +local week_max, week_min = -100, 100 +for i = 1, #maxes do + if maxes[i] > week_max then week_max = maxes[i] end + if mins[i] < week_min then week_min = mins[i] end +end + +local line = string.format("Неделя в %s: днём от %d до %d градусов, ночью до %d.", + city or "вашем городе", + math.floor(week_min + 0.5), + math.floor(week_max + 0.5), + math.floor(week_min + 0.5)) + +return jarvis.cmd.ok(line) diff --git a/resources/commands/websearch/command.toml b/resources/commands/websearch/command.toml new file mode 100644 index 0000000..a6f5db5 --- /dev/null +++ b/resources/commands/websearch/command.toml @@ -0,0 +1,67 @@ +[[commands]] +id = "search_google" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "загугли", + "поиск в гугл", + "поищи в гугл", + "найди в гугл", + "ищи в гугл", +] +en = ["google", "search google", "google for"] + + +[[commands]] +id = "search_youtube" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "найди на ютубе", + "поиск на ютубе", + "ищи на ютубе", + "ютуб поиск", +] +en = ["youtube search", "search youtube", "find on youtube"] + + +[[commands]] +id = "search_wiki" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "найди в википедии", + "поиск в википедии", + "википедия", + "вики поиск", +] +en = ["wikipedia search", "search wikipedia", "look up wikipedia"] + + +[[commands]] +id = "search_yandex" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "поищи в яндекс", + "найди в яндекс", + "яндекс поиск", + "пробей в яндекс", +] +en = ["yandex search", "search yandex"] diff --git a/resources/commands/websearch/dispatch.lua b/resources/commands/websearch/dispatch.lua new file mode 100644 index 0000000..8b4ea97 --- /dev/null +++ b/resources/commands/websearch/dispatch.lua @@ -0,0 +1,61 @@ +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() +local cmd_id = jarvis.context.command_id + +local services = { + search_google = { base = "https://www.google.com/search?q=", label = "Google" }, + search_youtube = { base = "https://www.youtube.com/results?search_query=", label = "YouTube" }, + search_wiki = { base = "https://ru.wikipedia.org/wiki/Special:Search?search=", label = "Wikipedia" }, + search_yandex = { base = "https://yandex.ru/search/?text=", label = "Yandex" }, +} + +local svc = services[cmd_id] +if not svc then + jarvis.log("error", "websearch: unknown command_id " .. tostring(cmd_id)) + jarvis.audio.play_error() + return { chain = false } +end + +local triggers = { + "загугли", "поиск в гугл", "поищи в гугл", "найди в гугл", "ищи в гугл", + "найди на ютубе", "поиск на ютубе", "ищи на ютубе", "ютуб поиск", + "найди в википедии", "поиск в википедии", "википедия", "вики поиск", + "поищи в яндекс", "найди в яндекс", "яндекс поиск", "пробей в яндекс", + "search google", "google for", "google", + "youtube search", "search youtube", "find on youtube", + "wikipedia search", "search wikipedia", "look up wikipedia", + "yandex search", "search yandex", + "загугли", "пошукай в гугл", + "знайди на ютубі", "пошук на ютубі", + "знайди в вікіпедії", "пошук у вікіпедії", + "знайди в яндекс", +} + +local q = phrase +for _, t in ipairs(triggers) do + local s, e = string.find(q, t, 1, true) + if s == 1 then + q = q:sub(e + 1) + break + end +end +q = q:gsub("^%s+", ""):gsub("%s+$", "") + +if q == "" then + jarvis.system.notify(svc.label, lang == "ru" and "Что искать?" or "What to search?") + jarvis.audio.play_error() + return { chain = false } +end + +local function url_encode(s) + return (s:gsub("([^%w%-_%.~])", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local url = svc.base .. url_encode(q) +jarvis.log("info", "websearch " .. svc.label .. ": " .. q) +jarvis.system.open(url) +jarvis.system.notify(svc.label, q) +jarvis.audio.play_ok() +return { chain = false } diff --git a/resources/commands/wikipedia/command.toml b/resources/commands/wikipedia/command.toml new file mode 100644 index 0000000..89a0a7e --- /dev/null +++ b/resources/commands/wikipedia/command.toml @@ -0,0 +1,33 @@ +# Wikipedia summary — fetch a topic's intro from Wikipedia REST API, speak it. + +[[commands]] +id = "wikipedia.summary" +type = "lua" +script = "summary.lua" +sandbox = "full" +timeout = 15000 + +[commands.phrases] +ru = [ + "что такое", + "кто такой", + "кто такая", + "расскажи про", + "википедия", + "найди в википедии", + "что говорит википедия", + "поищи в википедии", + "википедия о", + "википедия про", + "найди в википедии про", + "статья из википедии", +] +en = [ + "wikipedia", + "look up in wikipedia", + "wikipedia article on", + "what is", + "who is", + "tell me about", + "wikipedia about", +] diff --git a/resources/commands/wikipedia/summary.lua b/resources/commands/wikipedia/summary.lua new file mode 100644 index 0000000..9a90517 --- /dev/null +++ b/resources/commands/wikipedia/summary.lua @@ -0,0 +1,105 @@ +-- "Википедия Эйнштейн" → fetch ru.wikipedia.org/api/rest_v1/page/summary/ +-- Falls back to LLM if Wikipedia doesn't have the article. +local phrase = (jarvis.context.phrase or "") + +local query = jarvis.text.strip_trigger(phrase:lower(), { + "найди в википедии про", + "найди в википедии", + "что говорит википедия", + "поищи в википедии", + "статья из википедии", + "википедия про", + "википедия о", + "википедия", + "расскажи про", + "что такое", + "кто такая", + "кто такой", + "look up in wikipedia", + "wikipedia article on", + "wikipedia about", + "wikipedia", + "tell me about", + "what is", + "who is", + "знайди у вікіпедії", + "вікіпедія", + "що таке", + "хто такий", + "розкажи про", +}) +query = query:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if query == "" then + return jarvis.cmd.error("Какая статья?") +end + +-- Wikipedia title needs underscores for spaces. Capitalize first letter of each word +-- to match canonical titles (good enough heuristic). +local title = query:gsub("(%S+)", function(w) + -- Capitalize first byte (works for ASCII; for cyrillic, leave alone). + local first = w:sub(1, 1) + if first:match("[%a]") then + return first:upper() .. w:sub(2) + end + return w +end):gsub(" ", "_") + +local function urlencode(s) + return (s:gsub("[^A-Za-z0-9%-_%.~]", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +-- Try Russian first, fall back to English. +local lang_chain = { "ru", "en" } +local data, source_lang + +for _, lang in ipairs(lang_chain) do + local url = string.format("https://%s.wikipedia.org/api/rest_v1/page/summary/%s", + lang, urlencode(title)) + local d = jarvis.http.json(url) + if d and d.extract and d.extract ~= "" then + data = d + source_lang = lang + break + end +end + +if not data then + -- last resort: ask LLM directly + local llm_answer = jarvis.llm({ + { role = "system", content = "Кратко (2-3 предложения) на русском объясни тему. Если не знаешь — скажи." }, + { role = "user", content = "Расскажи про: " .. query } + }, { max_tokens = 200, temperature = 0.3 }) + if llm_answer and llm_answer ~= "" then + return jarvis.cmd.ok(llm_answer) + end + return jarvis.cmd.not_found("В Википедии нет статьи про " .. query .. ".") +end + +local extract = data.extract +local title_actual = data.title or query + +-- Truncate extract to ~3 sentences for spoken output +local sentences = {} +for sentence in extract:gmatch("[^%.%!%?]+[%.%!%?]") do + table.insert(sentences, sentence) + if #sentences >= 3 then break end +end +local spoken = #sentences > 0 and table.concat(sentences) or extract:sub(1, 400) + +if source_lang == "en" then + -- Translate English extract to Russian via LLM (no point in speaking English + -- through a Russian voice). + local translated = jarvis.llm({ + { role = "system", content = "Переведи английский текст на русский, без вводных и комментариев." }, + { role = "user", content = spoken } + }, { max_tokens = 300, temperature = 0.0 }) + if translated and translated ~= "" then + spoken = translated + end +end + +jarvis.system.notify("Wikipedia: " .. title_actual, spoken:sub(1, 200)) +return jarvis.cmd.ok(spoken) diff --git a/resources/commands/window/_window_helper.ps1 b/resources/commands/window/_window_helper.ps1 new file mode 100644 index 0000000..522d336 --- /dev/null +++ b/resources/commands/window/_window_helper.ps1 @@ -0,0 +1,42 @@ +param( + [Parameter(Mandatory=$true)] + [string]$Combo +) + +Add-Type -Name WinKey -Namespace Win32 -MemberDefinition @' +[DllImport("user32.dll")] +public static extern void keybd_event(byte vk, byte sc, uint flags, System.UIntPtr extra); +'@ + +$VK = @{ + 'win'=0x5B; 'lwin'=0x5B; 'rwin'=0x5C; + 'alt'=0x12; 'ctrl'=0x11; 'shift'=0x10; + 'a'=0x41; 'b'=0x42; 'c'=0x43; 'd'=0x44; 'e'=0x45; 'f'=0x46; + 'g'=0x47; 'h'=0x48; 'i'=0x49; 'j'=0x4A; 'k'=0x4B; 'l'=0x4C; + 'm'=0x4D; 'n'=0x4E; 'o'=0x4F; 'p'=0x50; 'q'=0x51; 'r'=0x52; + 's'=0x53; 't'=0x54; 'u'=0x55; 'v'=0x56; 'w'=0x57; 'x'=0x58; + 'y'=0x59; 'z'=0x5A; + 'up'=0x26; 'down'=0x28; 'left'=0x25; 'right'=0x27; + 'tab'=0x09; 'esc'=0x1B; 'enter'=0x0D; 'space'=0x20; 'home'=0x24; 'end'=0x23; + 'f1'=0x70; 'f2'=0x71; 'f3'=0x72; 'f4'=0x73; 'f5'=0x74; 'f6'=0x75; + 'f7'=0x76; 'f8'=0x77; 'f9'=0x78; 'f10'=0x79; 'f11'=0x7A; 'f12'=0x7B; +} + +$parts = ($Combo.ToLower() -split '\+') | ForEach-Object { $_.Trim() } +$codes = @() +foreach ($k in $parts) { + if (-not $VK.ContainsKey($k)) { + Write-Error "Unknown key: $k (in combo: $Combo)" + exit 2 + } + $codes += [byte][int]$VK[$k] +} + +foreach ($c in $codes) { + [Win32.WinKey]::keybd_event($c, 0, 0, [UIntPtr]::Zero) +} +Start-Sleep -Milliseconds 35 +[array]::Reverse($codes) +foreach ($c in $codes) { + [Win32.WinKey]::keybd_event($c, 0, 2, [UIntPtr]::Zero) +} diff --git a/resources/commands/window/command.toml b/resources/commands/window/command.toml new file mode 100644 index 0000000..7a8cd5d --- /dev/null +++ b/resources/commands/window/command.toml @@ -0,0 +1,94 @@ +[[commands]] +id = "show_desktop" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["покажи рабочий стол", "свернуть всё", "сверни все окна", "покажи десктоп"] +en = ["show desktop", "minimize all"] + + +[[commands]] +id = "maximize_window" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["разверни окно", "развернуть окно", "на весь экран", "раскрой окно"] +en = ["maximize window", "fullscreen window"] + + +[[commands]] +id = "minimize_window" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["сверни окно", "свернуть окно", "убери окно"] +en = ["minimize window"] + + +[[commands]] +id = "snap_left" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["окно влево", "сдвинь влево", "прижми влево"] +en = ["snap left", "window left"] + + +[[commands]] +id = "snap_right" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["окно вправо", "сдвинь вправо", "прижми вправо"] +en = ["snap right", "window right"] + + +[[commands]] +id = "close_window" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["закрой окно", "закрой вкладку", "закрой текущее", "alt f4"] +en = ["close window", "close tab"] + + +[[commands]] +id = "restore_all" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["восстанови окна", "верни окна", "разверни окна"] +en = ["restore windows", "restore all"] + + +[[commands]] +id = "task_view" +type = "lua" +script = "dispatch.lua" +sandbox = "full" +timeout = 3000 + +[commands.phrases] +ru = ["открой все окна", "обзор окон", "таск вью", "покажи все окна"] +en = ["task view", "show all windows"] diff --git a/resources/commands/window/dispatch.lua b/resources/commands/window/dispatch.lua new file mode 100644 index 0000000..9a4fdcf --- /dev/null +++ b/resources/commands/window/dispatch.lua @@ -0,0 +1,36 @@ +local helper = jarvis.context.command_path .. "\\_window_helper.ps1" +local cmd_id = jarvis.context.command_id + +local combos = { + show_desktop = "win+d", + maximize_window = "win+up", + minimize_window = "win+down", + snap_left = "win+left", + snap_right = "win+right", + close_window = "alt+f4", + restore_all = "win+shift+m", + task_view = "win+tab", +} + +local combo = combos[cmd_id] +if not combo then + jarvis.log("error", "window/dispatch.lua: unknown command_id: " .. tostring(cmd_id)) + jarvis.audio.play_error() + return { chain = false } +end + +local cmd = string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s" -Combo "%s"', + helper, combo +) +local res = jarvis.system.exec(cmd) + +if res.success then + jarvis.log("info", "window cmd " .. cmd_id .. " (" .. combo .. ")") + jarvis.audio.play_ok() +else + jarvis.log("error", "window cmd " .. combo .. " failed: " .. tostring(res.stderr)) + jarvis.audio.play_error() +end + +return { chain = false } diff --git a/resources/commands/window_switch/command.toml b/resources/commands/window_switch/command.toml new file mode 100644 index 0000000..21f692d --- /dev/null +++ b/resources/commands/window_switch/command.toml @@ -0,0 +1,20 @@ +[[commands]] +id = "switch_to_window" +type = "lua" +script = "switch.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "переключись на", + "переключи на", + "активируй окно", + "покажи окно", + "перейди в", +] +en = [ + "switch to", + "activate window", + "focus window", +] diff --git a/resources/commands/window_switch/switch.lua b/resources/commands/window_switch/switch.lua new file mode 100644 index 0000000..b03f61d --- /dev/null +++ b/resources/commands/window_switch/switch.lua @@ -0,0 +1,51 @@ +local lang = jarvis.context.language +local triggers = { + "переключись на", "переключи на", "активируй окно", "покажи окно", "перейди в", + "switch to", "activate window", "focus window", + "перемкни на", "активуй вікно", +} +local query = jarvis.text.strip_trigger(jarvis.context.phrase or "", triggers):lower() +if query == "" then + jarvis.system.notify("Switch", lang == "ru" and "Какое окно?" or "Which window?") + jarvis.audio.play_error() + return { chain = false } +end + +local aliases = { + ["хром"] = "chrome", ["хрома"] = "chrome", + ["едж"] = "msedge", ["эдж"] = "msedge", + ["файрфокс"] = "firefox", ["фаерфокс"] = "firefox", + ["вскод"] = "code", ["vs code"] = "code", + ["дискорд"] = "discord", + ["телега"] = "telegram", ["телеграм"] = "telegram", + ["стим"] = "steam", + ["обс"] = "obs64", + ["проводник"] = "explorer", + ["блокнот"] = "notepad", + ["терминал"] = "windowsterminal", + ["спотифай"] = "spotify", + ["яндекс"] = "browser", + ["браузер"] = "browser", +} +local target = aliases[query] or query + +local ps = string.format([[ +$procs = Get-Process | Where-Object { $_.MainWindowTitle -and ($_.ProcessName -like '*%s*' -or $_.MainWindowTitle -like '*%s*') } | Sort-Object -Property StartTime -Descending +if ($procs.Count -eq 0) { Write-Output 'NOT_FOUND'; exit 0 } +$p = $procs[0] +$wsh = New-Object -ComObject WScript.Shell +$null = $wsh.AppActivate($p.Id) +Write-Output $p.MainWindowTitle +]], target:gsub("'", ""), target:gsub("'", "")) + +local res = jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"'))) +local out = (res.stdout or ""):gsub("[\r\n]+$", ""):gsub("^%s+", "") + +if out == "NOT_FOUND" or out == "" then + jarvis.system.notify("Switch", (lang == "ru" and "Не нашёл: " or "Not found: ") .. query) + jarvis.audio.play_not_found() +else + jarvis.system.notify(lang == "ru" and "Переключаюсь" or "Switching", out:sub(1, 80)) + jarvis.audio.play_ok() +end +return { chain = false } diff --git a/resources/commands/wol/command.toml b/resources/commands/wol/command.toml new file mode 100644 index 0000000..73df56a --- /dev/null +++ b/resources/commands/wol/command.toml @@ -0,0 +1,31 @@ +# Wake-on-LAN — fire a magic packet at a target MAC address. The target is +# read from long-term memory under the key "wol_" so the user can have +# multiple machines. Default alias is "server". +# +# To set a target via voice: +# "запомни wol_server AA:BB:CC:DD:EE:FF" +# then later: "разбуди сервер" + +[[commands]] +id = "wol.wake" +type = "lua" +script = "wake.lua" +sandbox = "full" +timeout = 5000 + +[commands.phrases] +ru = [ + "разбуди сервер", + "включи сервер", + "разбуди машину", + "разбуди компьютер", + "включи компьютер", + "wol сервер", +] +en = [ + "wake the server", + "wake server", + "wake on lan", + "wol server", + "boot the server", +] diff --git a/resources/commands/wol/wake.lua b/resources/commands/wol/wake.lua new file mode 100644 index 0000000..056231a --- /dev/null +++ b/resources/commands/wol/wake.lua @@ -0,0 +1,78 @@ +-- Wake-on-LAN — sends a magic packet to a target MAC stored in long-term memory. +-- +-- Setup (one-time, via voice): +-- "запомни wol_server AA:BB:CC:DD:EE:FF" +-- Then later, anytime: +-- "разбуди сервер" → packet goes out to broadcast. +-- +-- Lua can't open raw UDP, so we shell out to PowerShell (System.Net.Sockets). + +local lang = jarvis.context.language +local phrase = (jarvis.context.phrase or ""):lower() + +-- Figure out which alias the user wants. Right now we only support "server" +-- (the most common case) but the data model already supports more. +local alias = "server" + +local mac = jarvis.memory.recall("wol_" .. alias) +if not mac or mac == "" then + -- Fall back to env so power users can ship a default without touching memory. + mac = jarvis.system.env("JARVIS_WOL_TARGET") or "" +end + +if mac == "" then + return jarvis.cmd.error(lang == "ru" + and "Сэр, MAC-адрес не задан. Скажи: запомни wol_server, а потом адрес." + or "Sir, MAC address is not set. Say: remember wol_server, then the address.") +end + +-- Normalise: AA-BB-CC-DD-EE-FF, AA:BB:CC:DD:EE:FF, AABB.CCDD.EEFF, AABBCCDDEEFF +local cleaned = mac:gsub("[^%w]", ""):upper() +if #cleaned ~= 12 or not cleaned:match("^[0-9A-F]+$") then + return jarvis.cmd.error(lang == "ru" + and "Сэр, MAC-адрес в памяти выглядит подозрительно: " .. tostring(mac) + or "Sir, the stored MAC looks malformed: " .. tostring(mac)) +end + +-- Inject the cleaned MAC as a constant in the PowerShell script so we don't +-- have to argv-escape it. The packet is 6 × 0xFF + 16 × MAC = 102 bytes, +-- broadcast on UDP/9. +local ps_template = [[ +$mac = '%s' +$macBytes = @() +for ($i = 0; $i -lt 12; $i += 2) { + $macBytes += [Convert]::ToByte($mac.Substring($i, 2), 16) +} +$packet = New-Object byte[] 102 +for ($i = 0; $i -lt 6; $i++) { $packet[$i] = 0xFF } +for ($r = 0; $r -lt 16; $r++) { + for ($j = 0; $j -lt 6; $j++) { + $packet[6 + $r * 6 + $j] = $macBytes[$j] + } +} +$udp = New-Object System.Net.Sockets.UdpClient +$udp.EnableBroadcast = $true +$null = $udp.Send($packet, $packet.Length, '255.255.255.255', 9) +$udp.Close() +Write-Output 'sent' +]] + +local ps = string.format(ps_template, cleaned) +local cmd = 'powershell -NoProfile -ExecutionPolicy Bypass -Command "& {' + .. ps:gsub('\r?\n', '; '):gsub('"', '\\"') + .. '}"' + +jarvis.log("info", "WOL packet → " .. cleaned) +local res = jarvis.system.exec(cmd) +if not res.success then + jarvis.log("error", "WOL failed: " .. tostring(res.stderr)) + return jarvis.cmd.error(lang == "ru" + and "Не получилось отправить пакет." + or "Failed to send packet.") +end + +-- Format the MAC back for speech: "AA-BB-CC-..." sounds clearer than "AABB..." +local pretty = cleaned:gsub("(%w%w)", "%1-"):sub(1, -2) +return jarvis.cmd.ok(lang == "ru" + and ("Пакет отправлен на " .. pretty) + or ("Packet sent to " .. pretty)) diff --git a/resources/commands/world_clock/command.toml b/resources/commands/world_clock/command.toml new file mode 100644 index 0000000..c6d515c --- /dev/null +++ b/resources/commands/world_clock/command.toml @@ -0,0 +1,17 @@ +# World clock — query time in another city/timezone. + +[[commands]] +id = "world_clock" +type = "lua" +script = "time_in.lua" +sandbox = "full" +timeout = 10000 + +[commands.phrases] +ru = [ + "сколько времени в", + "который час в", + "сколько часов в", + "время в", +] +en = ["time in", "what time in"] diff --git a/resources/commands/world_clock/time_in.lua b/resources/commands/world_clock/time_in.lua new file mode 100644 index 0000000..dd561ec --- /dev/null +++ b/resources/commands/world_clock/time_in.lua @@ -0,0 +1,95 @@ +-- "Сколько времени в Токио" — uses worldtimeapi.org public API. +local phrase = (jarvis.context.phrase or "") +local city = jarvis.text.strip_trigger(phrase:lower(), { + "сколько времени в", + "который час в", + "сколько часов в", + "время в", + "time in", + "what time in", + "котра година у", + "скільки часу в", +}) +city = city:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if city == "" then + return jarvis.cmd.error("В каком городе?") +end + +-- Map common Russian city names → timezone slugs. +local TZ_MAP = { + -- Russia + ["москва"] = "Europe/Moscow", + ["спб"] = "Europe/Moscow", + ["петербург"] = "Europe/Moscow", + ["санкт-петербург"] = "Europe/Moscow", + ["новосибирск"] = "Asia/Novosibirsk", + ["екатеринбург"] = "Asia/Yekaterinburg", + ["казань"] = "Europe/Moscow", + ["владивосток"] = "Asia/Vladivostok", + ["хабаровск"] = "Asia/Vladivostok", + ["омск"] = "Asia/Omsk", + ["красноярск"] = "Asia/Krasnoyarsk", + -- World + ["лондон"] = "Europe/London", + ["нью-йорк"] = "America/New_York", + ["нью йорк"] = "America/New_York", + ["сан-франциско"] = "America/Los_Angeles", + ["сан франциско"] = "America/Los_Angeles", + ["лос-анджелес"] = "America/Los_Angeles", + ["лос анджелес"] = "America/Los_Angeles", + ["токио"] = "Asia/Tokyo", + ["пекин"] = "Asia/Shanghai", + ["шанхай"] = "Asia/Shanghai", + ["гонконг"] = "Asia/Hong_Kong", + ["сингапур"] = "Asia/Singapore", + ["сидней"] = "Australia/Sydney", + ["дубай"] = "Asia/Dubai", + ["париж"] = "Europe/Paris", + ["берлин"] = "Europe/Berlin", + ["мадрид"] = "Europe/Madrid", + ["рим"] = "Europe/Rome", + ["амстердам"] = "Europe/Amsterdam", + ["варшава"] = "Europe/Warsaw", + ["прага"] = "Europe/Prague", + ["стамбул"] = "Europe/Istanbul", + ["киев"] = "Europe/Kyiv", + ["минск"] = "Europe/Minsk", + ["ереван"] = "Asia/Yerevan", + ["тбилиси"] = "Asia/Tbilisi", + ["алматы"] = "Asia/Almaty", + ["астана"] = "Asia/Almaty", + ["баку"] = "Asia/Baku", + ["ташкент"] = "Asia/Tashkent", + ["сеул"] = "Asia/Seoul", + ["мехико"] = "America/Mexico_City", + ["рио"] = "America/Sao_Paulo", + ["буэнос-айрес"] = "America/Argentina/Buenos_Aires", +} + +local tz = nil +for stem, zone in pairs(TZ_MAP) do + if city:find(stem, 1, true) then + tz = zone + break + end +end + +if not tz then + return jarvis.cmd.not_found("Не знаю такого города. Попробуйте Москва, Токио, Лондон.") +end + +-- worldtimeapi.org has rate limits but is free with no key. +local url = "https://worldtimeapi.org/api/timezone/" .. tz +local data = jarvis.http.json(url) +if not data or not data.datetime then + return jarvis.cmd.error("Не получилось узнать время.") +end + +-- datetime is ISO 8601: "2026-05-15T14:32:11.123456+09:00" +local hh, mm = data.datetime:match("T(%d%d):(%d%d)") +if not hh then + return jarvis.cmd.error("Не понял ответ сервиса.") +end + +return jarvis.cmd.ok(string.format("В %s сейчас %s:%s.", city, hh, mm)) diff --git a/resources/icons/icon.ico b/resources/icons/icon.ico index 4ca0662..ca7f09c 100644 Binary files a/resources/icons/icon.ico and b/resources/icons/icon.ico differ diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..66650c8 --- /dev/null +++ b/run.bat @@ -0,0 +1,19 @@ +@echo off +setlocal enabledelayedexpansion +cd /d "%~dp0" + +if exist dev.env ( + for /f "usebackq tokens=1,* delims==" %%a in ("dev.env") do ( + set "_k=%%a" + if not "!_k:~0,1!"=="#" if not "!_k!"=="" set "%%a=%%b" + ) +) + +if exist "target\release\jarvis-gui.exe" ( + start "" "target\release\jarvis-gui.exe" + exit /b 0 +) + +echo target\release\jarvis-gui.exe not found. +echo Build it first: cargo build --release -p jarvis-gui +pause diff --git a/tools/piper/README.md b/tools/piper/README.md new file mode 100644 index 0000000..0079863 --- /dev/null +++ b/tools/piper/README.md @@ -0,0 +1,64 @@ +# Piper TTS (neural voice for J.A.R.V.I.S.) + +Drop-in replacement for the robotic SAPI voice. ~30 MB binary + ~60 MB Russian voice (Irina). Latency 200-500ms on i5. + +## Install + +```powershell +cd C:\Jarvis\rust\tools\piper +pwsh -File install.ps1 +``` + +By default installs `ru_RU-irina-medium` (Russian female). To pick a different voice: + +```powershell +pwsh -File install.ps1 -Voice ru_RU-denis-medium +pwsh -File install.ps1 -Voice ru_RU-dmitri-medium +pwsh -File install.ps1 -Voice ru_RU-ruslan-medium +pwsh -File install.ps1 -Voice en_US-amy-medium # English female +``` + +Force re-download: + +```powershell +pwsh -File install.ps1 -Force +``` + +## Use + +If `piper.exe` + a voice file are present in `tools/piper/`, J.A.R.V.I.S. auto-detects Piper on startup. No env vars required. + +To force a specific backend: + +```bat +set JARVIS_TTS=piper :: use Piper +set JARVIS_TTS=sapi :: force SAPI fallback +set JARVIS_TTS=silero :: use Silero (needs tools/silero/silero_tts.py) +``` + +Override paths: + +```bat +set JARVIS_TTS_PIPER_BIN=D:\custom\piper.exe +set JARVIS_TTS_PIPER_VOICE=D:\voices\my-voice.onnx +``` + +## Voices catalogue + +Full list: https://huggingface.co/rhasspy/piper-voices + +Recommended for Russian: +- `ru_RU-irina-medium` — natural female, default +- `ru_RU-dmitri-medium` — male, clear diction +- `ru_RU-ruslan-medium` — male, slightly deeper +- `ru_RU-denis-medium` — male, "newsreader" tone + +## Troubleshooting + +**"piper.exe not found"** — install script failed; check `install.ps1` output. The fallback is SAPI — assistant still works, just with the robotic voice. + +**"no .onnx voice in voices/"** — voice download failed (HuggingFace blocked?). Manually download from https://huggingface.co/rhasspy/piper-voices/tree/main/ru/ru_RU/irina/medium and drop both `.onnx` + `.onnx.json` into `voices/`. + +**Voice sounds wrong** — try a different model. Each Russian voice has a different timbre. + +**No audio output** — Piper writes to a temp wav, then plays via `System.Media.SoundPlayer` synchronously. Check that the default playback device isn't muted. diff --git a/tools/piper/install.ps1 b/tools/piper/install.ps1 new file mode 100644 index 0000000..3f8a3c4 --- /dev/null +++ b/tools/piper/install.ps1 @@ -0,0 +1,108 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Download Piper TTS engine + Russian voice into tools/piper/. + +.DESCRIPTION + Fetches the latest piper_windows_amd64.zip from rhasspy/piper-binary and + the Irina (ru_RU) voice .onnx + .json from huggingface rhasspy/piper-voices. + Idempotent — skips if files already exist. Pass -Force to re-download. + +.PARAMETER Voice + Voice name to install. Default "ru_RU-irina-medium". Other options: + ru_RU-denis-medium, ru_RU-dmitri-medium, ru_RU-ruslan-medium, en_US-amy-medium, ... + +.PARAMETER Force + Re-download even if files exist. + +.EXAMPLE + pwsh -File install.ps1 + pwsh -File install.ps1 -Voice ru_RU-denis-medium + pwsh -File install.ps1 -Force +#> + +param( + [string]$Voice = "ru_RU-irina-medium", + [switch]$Force +) + +$ErrorActionPreference = "Stop" +$InformationPreference = "Continue" + +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$BinPath = Join-Path $Root "piper.exe" +$VoiceDir = Join-Path $Root "voices" +$VoiceFile = Join-Path $VoiceDir "$Voice.onnx" +$VoiceJson = Join-Path $VoiceDir "$Voice.onnx.json" + +# ─── Piper binary ────────────────────────────────────────────────────────── +if ((Test-Path $BinPath) -and -not $Force) { + Write-Information "[skip] piper.exe already present at $BinPath" +} else { + Write-Information "[fetch] piper_windows_amd64.zip..." + $zip = Join-Path $env:TEMP "piper_windows_amd64.zip" + $url = "https://github.com/rhasspy/piper/releases/latest/download/piper_windows_amd64.zip" + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + Write-Information "[unzip] -> $Root" + Expand-Archive -Path $zip -DestinationPath $Root -Force + # piper distributes inside a "piper/" subfolder — flatten it + $inner = Join-Path $Root "piper" + if (Test-Path $inner) { + Get-ChildItem -Path $inner -Force | Move-Item -Destination $Root -Force + Remove-Item -Path $inner -Recurse -Force + } + Remove-Item $zip -Force + if (-not (Test-Path $BinPath)) { + throw "piper.exe not found after extraction" + } + Write-Information "[ok] piper installed at $BinPath" +} + +# ─── Voice files ─────────────────────────────────────────────────────────── +if (-not (Test-Path $VoiceDir)) { + New-Item -ItemType Directory -Path $VoiceDir | Out-Null +} + +# Voice naming: ru_RU-irina-medium -> lang=ru_RU, name=irina, quality=medium +$parts = $Voice -split "-" +if ($parts.Length -lt 3) { + throw "Bad voice name '$Voice' — expected --" +} +$lang = $parts[0] +$name = $parts[1] +$quality = $parts[2] +$langCode = ($lang -split "_")[0] + +$Base = "https://huggingface.co/rhasspy/piper-voices/resolve/main/$langCode/$lang/$name/$quality" + +foreach ($pair in @( + @{ url = "$Base/$Voice.onnx"; out = $VoiceFile }, + @{ url = "$Base/$Voice.onnx.json"; out = $VoiceJson } +)) { + if ((Test-Path $pair.out) -and -not $Force) { + Write-Information "[skip] $(Split-Path -Leaf $pair.out) already present" + continue + } + Write-Information "[fetch] $($pair.url)" + Invoke-WebRequest -Uri $pair.url -OutFile $pair.out -UseBasicParsing + Write-Information "[ok] $(Split-Path -Leaf $pair.out) -> $($pair.out)" +} + +# ─── Smoke test ──────────────────────────────────────────────────────────── +$testWav = Join-Path $env:TEMP "jarvis-piper-test.wav" +try { + Write-Information "[test] synthesizing 'Привет, Дмитрий'..." + "Привет, Дмитрий" | & $BinPath --model $VoiceFile --output_file $testWav 2>$null + if ((Test-Path $testWav) -and ((Get-Item $testWav).Length -gt 1000)) { + Write-Information "[ok] Piper works! Test wav: $testWav" + Write-Information "" + Write-Information "Next: set environment variable JARVIS_TTS=piper" + Write-Information " (or just leave it unset — auto-detect will pick Piper)" + } else { + Write-Warning "Test wav empty or missing — synthesis may have failed" + } +} catch { + Write-Warning "Smoke test failed: $_" +} finally { + if (Test-Path $testWav) { Remove-Item $testWav -Force } +} diff --git a/tools/silero/silero_tts.py b/tools/silero/silero_tts.py new file mode 100644 index 0000000..42ae611 --- /dev/null +++ b/tools/silero/silero_tts.py @@ -0,0 +1,85 @@ +"""Silero TTS helper for J.A.R.V.I.S. + +Reads text from stdin, synthesises with snakers4/silero-models, writes the result +to a temp wav, and prints the path on stdout. The Rust side then plays + deletes it. + +Install: + pip install torch soundfile numpy + +Usage (called by jarvis-core/src/tts/silero.rs): + echo "привет" | python silero_tts.py --voice xenia + +Voices for ru_v3: + xenia (female, default), baya (female), aidar (male), eugene (male), kseniya (female). +""" + +import argparse +import os +import sys +import tempfile + +try: + import torch # type: ignore +except ImportError: + print("silero_tts: torch not installed. Run `pip install torch`.", file=sys.stderr) + sys.exit(2) + +import warnings + +warnings.filterwarnings("ignore") + +_MODEL = None + + +def load_model(device: str = "cpu"): + global _MODEL + if _MODEL is None: + torch.set_num_threads(4) + _MODEL, _ = torch.hub.load( + repo_or_dir="snakers4/silero-models", + model="silero_tts", + language="ru", + speaker="v3_1_ru", + trust_repo=True, + ) + _MODEL.to(torch.device(device)) + return _MODEL + + +def synth(text: str, voice: str, sample_rate: int = 48000) -> str: + """Synth text → return path to temp wav.""" + model = load_model() + audio = model.apply_tts(text=text, speaker=voice, sample_rate=sample_rate) + + import numpy as np + import soundfile as sf # type: ignore + + fd, path = tempfile.mkstemp(prefix="jarvis-silero-", suffix=".wav") + os.close(fd) + sf.write(path, audio.numpy().astype(np.float32), sample_rate) + return path + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--voice", default="xenia", help="speaker name (xenia, baya, aidar, eugene, kseniya)") + ap.add_argument("--sample-rate", type=int, default=48000) + args = ap.parse_args() + + text = sys.stdin.read().strip() + if not text: + print("silero_tts: empty input", file=sys.stderr) + return 1 + + try: + path = synth(text, args.voice, args.sample_rate) + except Exception as exc: + print(f"silero_tts: synth failed: {exc}", file=sys.stderr) + return 3 + + print(path) + return 0 + + +if __name__ == "__main__": + sys.exit(main())