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/crates/jarvis-app/src/llm_fallback.rs b/crates/jarvis-app/src/llm_fallback.rs index bbfc4f8..5bc91da 100644 --- a/crates/jarvis-app/src/llm_fallback.rs +++ b/crates/jarvis-app/src/llm_fallback.rs @@ -137,7 +137,7 @@ pub fn handle(prompt: &str) { } fn speak_reply(text: &str) { - if std::env::var("JARVIS_LLM_TTS").as_deref() == Ok("false") { + 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 index 0f17883..267179c 100644 --- a/crates/jarvis-app/src/llm_router.rs +++ b/crates/jarvis-app/src/llm_router.rs @@ -60,19 +60,15 @@ fn build_state() -> Option { return None; } }; - let threshold = std::env::var("JARVIS_LLM_ROUTER_THRESHOLD") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_THRESHOLD); + let threshold = jarvis_core::runtime_config::llm_router_threshold(); + let _ = DEFAULT_THRESHOLD; // kept for backward visibility in source info!("LLM router enabled (backend: {}, threshold {:.2}).", client.backend().name(), threshold); Some(State { client, threshold }) } fn router_enabled() -> bool { - std::env::var("JARVIS_LLM_ROUTER") - .map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on")) - .unwrap_or(true) // default ON when GROQ_TOKEN is present + jarvis_core::runtime_config::llm_router_enabled() && config::LLM_DEFAULT_ENABLED } diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 45c1475..cb43bc3 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -57,6 +57,9 @@ fn main() -> Result<(), String> { eprintln!("[jarvis-app] step: i18n::init"); i18n::init(&settings.lock().language); + eprintln!("[jarvis-app] step: runtime_config::log_effective_config"); + jarvis_core::runtime_config::log_effective_config(); + eprintln!("[jarvis-app] step: llm_fallback::init"); llm_fallback::init(); diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index 61a7027..b07b796 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -50,6 +50,8 @@ pub mod lua; pub mod text_utils; +pub mod runtime_config; + pub mod tts; pub mod long_term_memory; diff --git a/crates/jarvis-core/src/long_term_memory.rs b/crates/jarvis-core/src/long_term_memory.rs index 5e9e2c8..c84ec65 100644 --- a/crates/jarvis-core/src/long_term_memory.rs +++ b/crates/jarvis-core/src/long_term_memory.rs @@ -120,10 +120,30 @@ fn now_secs() -> i64 { .unwrap_or(0) } -fn normalize_key(k: &str) -> String { +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())?; @@ -169,18 +189,8 @@ pub fn recall(key: &str) -> Option { /// 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 nq = normalize_key(query); - if nq.is_empty() { - return Vec::new(); - } let store = state.store.read(); - let mut hits: Vec = store.entries.values() - .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 + search_in(store.entries.values(), query, limit) } /// Delete a fact. Returns true if it existed. @@ -205,13 +215,126 @@ pub fn all() -> Vec { /// 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 { + 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"); + } +} diff --git a/crates/jarvis-core/src/lua/api.rs b/crates/jarvis-core/src/lua/api.rs index 9c5adf4..4bbac74 100644 --- a/crates/jarvis-core/src/lua/api.rs +++ b/crates/jarvis-core/src/lua/api.rs @@ -11,4 +11,5 @@ pub mod text; pub mod memory; pub mod profile; pub mod vision; -pub mod scheduler; \ No newline at end of file +pub mod scheduler; +pub mod cmd; \ No newline at end of file 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/engine.rs b/crates/jarvis-core/src/lua/engine.rs index a580cf7..1c2f7b8 100644 --- a/crates/jarvis-core/src/lua/engine.rs +++ b/crates/jarvis-core/src/lua/engine.rs @@ -81,6 +81,7 @@ impl LuaEngine { api::memory::register(&self.lua, &jarvis)?; api::profile::register(&self.lua, &jarvis)?; api::scheduler::register(&self.lua, &jarvis)?; + api::cmd::register(&self.lua, &jarvis)?; // sandbox-controlled APIs if self.sandbox.allows_http() { diff --git a/crates/jarvis-core/src/profiles.rs b/crates/jarvis-core/src/profiles.rs index d87280e..a247ce8 100644 --- a/crates/jarvis-core/src/profiles.rs +++ b/crates/jarvis-core/src/profiles.rs @@ -227,3 +227,83 @@ pub fn list() -> Vec { names.sort(); names } + +#[cfg(test)] +mod tests { + use super::*; + + fn p_with(allowed: Vec<&str>, disabled: Vec<&str>) -> Profile { + Profile { + name: "test".into(), + description: String::new(), + llm_personality: String::new(), + allowed_command_prefixes: allowed.into_iter().map(String::from).collect(), + disabled_command_prefixes: disabled.into_iter().map(String::from).collect(), + greeting: String::new(), + icon: String::new(), + } + } + + #[test] + fn empty_lists_allow_everything() { + let p = p_with(vec![], vec![]); + assert!(p.allows_command("anything")); + assert!(p.allows_command("games.steam.launch")); + } + + #[test] + fn allowed_prefixes_act_as_whitelist() { + let p = p_with(vec!["music.", "time."], vec![]); + assert!(p.allows_command("music.spotify.play")); + assert!(p.allows_command("time.current")); + assert!(!p.allows_command("games.steam")); + assert!(!p.allows_command("windows.minimize")); + } + + #[test] + fn disabled_prefixes_act_as_blacklist() { + let p = p_with(vec![], vec!["games.", "fun."]); + assert!(!p.allows_command("games.steam.launch")); + assert!(!p.allows_command("fun.joke")); + assert!(p.allows_command("time.current")); + assert!(p.allows_command("weather.now")); + } + + #[test] + fn disabled_wins_over_allowed() { + // even if "media." is on the allow list, "media.spotify" being on + // the deny list must short-circuit to false. + let p = p_with(vec!["media."], vec!["media.spotify"]); + assert!(!p.allows_command("media.spotify.skip")); + assert!(p.allows_command("media.youtube.play")); + } + + #[test] + fn profile_serde_round_trip() { + let p = Profile { + name: "work".into(), + description: "Focus mode".into(), + llm_personality: "Be brief.".into(), + allowed_command_prefixes: vec!["time.".into(), "weather.".into()], + disabled_command_prefixes: vec!["games.".into()], + greeting: "Sir.".into(), + icon: "💼".into(), + }; + let json = serde_json::to_string(&p).unwrap(); + let back: Profile = serde_json::from_str(&json).unwrap(); + assert_eq!(back.name, "work"); + assert_eq!(back.allowed_command_prefixes.len(), 2); + assert_eq!(back.disabled_command_prefixes.len(), 1); + assert_eq!(back.icon, "💼"); + } + + #[test] + fn profile_serde_tolerates_missing_optional_fields() { + // Minimum-fields JSON should deserialize using #[serde(default)] on each field. + let minimal = r#"{"name":"minimal"}"#; + let p: Profile = serde_json::from_str(minimal).unwrap(); + assert_eq!(p.name, "minimal"); + assert!(p.description.is_empty()); + assert!(p.allowed_command_prefixes.is_empty()); + } +} diff --git a/crates/jarvis-core/src/runtime_config.rs b/crates/jarvis-core/src/runtime_config.rs new file mode 100644 index 0000000..aee907b --- /dev/null +++ b/crates/jarvis-core/src/runtime_config.rs @@ -0,0 +1,138 @@ +//! Centralised runtime configuration knobs. +//! +//! All environment-variable-driven behaviour in J.A.R.V.I.S. is documented here +//! and (for the most-used flags) wrapped in helper functions. Adding a new knob: +//! +//! 1. Add a `const ENV_*: &str = "JARVIS_..."` declaration below with a doc comment. +//! 2. Add a thin getter (e.g. `pub fn my_feature_enabled() -> bool`) below. +//! 3. Call it from the feature module instead of inlining `std::env::var(...)`. +//! +//! The goal is single-point discovery: open this file to learn every flag. +//! +//! For the on-disk settings (sqlite via `db::structs::Settings`), see `crate::db`. + +use std::env; + +// ─── TTS ─────────────────────────────────────────────────────────────────── + +/// TTS backend selector. Values: `sapi` | `piper` | `silero`. Unset → auto-detect +/// (Piper if `tools/piper/piper.exe` + voice present, else SAPI). +pub const ENV_TTS: &str = "JARVIS_TTS"; + +/// Absolute path to `piper.exe`. Default: `/tools/piper/piper.exe`. +pub const ENV_TTS_PIPER_BIN: &str = "JARVIS_TTS_PIPER_BIN"; + +/// Absolute path to Piper voice `.onnx`. Default: first .onnx in `/voices/`. +pub const ENV_TTS_PIPER_VOICE: &str = "JARVIS_TTS_PIPER_VOICE"; + +/// Python executable for Silero subprocess. Default: `python`. +pub const ENV_TTS_PYTHON: &str = "JARVIS_TTS_PYTHON"; + +/// Path to `silero_tts.py` helper. Default: `/tools/silero/silero_tts.py`. +pub const ENV_TTS_SILERO_HELPER: &str = "JARVIS_TTS_SILERO_HELPER"; + +/// Silero voice name. Default: `xenia`. Other ru_v3 voices: baya, aidar, eugene, kseniya. +pub const ENV_TTS_SILERO_VOICE: &str = "JARVIS_TTS_SILERO_VOICE"; + +/// Set to `false` to disable LLM-reply TTS playback (keep IPC event only). Default: enabled. +pub const ENV_LLM_TTS: &str = "JARVIS_LLM_TTS"; + +// ─── LLM ─────────────────────────────────────────────────────────────────── + +/// LLM backend selector. Values: `groq` | `ollama`. Auto-detect: Groq if +/// `GROQ_TOKEN` present, else Ollama. +pub const ENV_LLM: &str = "JARVIS_LLM"; + +/// Groq API token. Required for cloud LLM. Get one at https://groq.com. +pub const ENV_GROQ_TOKEN: &str = "GROQ_TOKEN"; + +/// Groq base URL. Default: `https://api.groq.com/openai/v1`. +pub const ENV_GROQ_BASE_URL: &str = "GROQ_BASE_URL"; + +/// Groq model. Default: `llama-3.3-70b-versatile`. +pub const ENV_GROQ_MODEL: &str = "GROQ_MODEL"; + +/// Groq vision-capable model for IMBA-4. Default: `llama-3.2-11b-vision-preview`. +pub const ENV_GROQ_VISION_MODEL: &str = "GROQ_VISION_MODEL"; + +/// Ollama base URL. Default: `http://localhost:11434/v1`. +pub const ENV_OLLAMA_BASE_URL: &str = "OLLAMA_BASE_URL"; + +/// Ollama model name. Default: `qwen2.5:3b`. Pull via `ollama pull `. +pub const ENV_OLLAMA_MODEL: &str = "OLLAMA_MODEL"; + +// ─── LLM router (IMBA-1) ─────────────────────────────────────────────────── + +/// Enable agentic LLM router. Values: `1` / `true` / `yes` / `on`. Default: `1` (on). +pub const ENV_LLM_ROUTER: &str = "JARVIS_LLM_ROUTER"; + +/// Confidence threshold (0.0..1.0) above which the router accepts the LLM's command pick. +/// Default: `0.55`. Lower → more matches but more false positives. +pub const ENV_LLM_ROUTER_THRESHOLD: &str = "JARVIS_LLM_ROUTER_THRESHOLD"; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +/// Read an env var, returning `None` for unset or whitespace-only. +pub fn get(name: &str) -> Option { + env::var(name).ok().and_then(|v| { + let trimmed = v.trim(); + if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } + }) +} + +/// Parse a boolean-ish env var. Accepts `1` / `true` / `yes` / `on` (case-insensitive) +/// as true, `0` / `false` / `no` / `off` as false. Returns `default` if unset. +pub fn get_bool(name: &str, default: bool) -> bool { + match get(name).map(|v| v.to_lowercase()) { + Some(s) => matches!(s.as_str(), "1" | "true" | "yes" | "on"), + None => default, + } +} + +/// Parse a typed env var. Returns `default` if unset or unparseable. +pub fn get_parse(name: &str, default: T) -> T { + get(name).and_then(|v| v.parse().ok()).unwrap_or(default) +} + +// ─── Feature-flag wrappers ───────────────────────────────────────────────── + +/// True if LLM-reply TTS is enabled (default). Set `JARVIS_LLM_TTS=false` to mute. +pub fn llm_tts_enabled() -> bool { get_bool(ENV_LLM_TTS, true) } + +/// True if agentic LLM router is enabled (default on). +pub fn llm_router_enabled() -> bool { get_bool(ENV_LLM_ROUTER, true) } + +/// Router confidence threshold; default 0.55. +pub fn llm_router_threshold() -> f32 { get_parse(ENV_LLM_ROUTER_THRESHOLD, 0.55) } + +/// Log every effective env-driven knob at INFO level. Call once on startup. +pub fn log_effective_config() { + log::info!("[config] TTS backend: {}", get(ENV_TTS).unwrap_or_else(|| "auto".into())); + log::info!("[config] LLM backend: {}", get(ENV_LLM).unwrap_or_else(|| "auto".into())); + log::info!("[config] LLM router: {} (threshold {:.2})", + llm_router_enabled(), llm_router_threshold()); + log::info!("[config] LLM reply TTS: {}", llm_tts_enabled()); + log::info!("[config] Groq model: {}", + get(ENV_GROQ_MODEL).unwrap_or_else(|| "llama-3.3-70b-versatile (default)".into())); + log::info!("[config] Ollama model: {}", + get(ENV_OLLAMA_MODEL).unwrap_or_else(|| "qwen2.5:3b (default)".into())); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_bool_defaults() { + assert!(!get_bool("JARVIS_NON_EXISTENT_XYZ", false)); + assert!(get_bool("JARVIS_NON_EXISTENT_XYZ", true)); + } + + #[test] + fn get_parse_typed_default() { + let n: u32 = get_parse("JARVIS_NON_EXISTENT_XYZ", 42); + assert_eq!(n, 42); + let f: f32 = get_parse("JARVIS_NON_EXISTENT_XYZ", 0.5); + assert!((f - 0.5).abs() < 0.001); + } +} diff --git a/crates/jarvis-core/src/tts/mod.rs b/crates/jarvis-core/src/tts/mod.rs index 84ee73c..2738b9e 100644 --- a/crates/jarvis-core/src/tts/mod.rs +++ b/crates/jarvis-core/src/tts/mod.rs @@ -13,6 +13,7 @@ //! If `JARVIS_TTS` is unset, the dispatcher auto-detects Piper, otherwise SAPI. use once_cell::sync::OnceCell; +use std::path::Path; use std::sync::Arc; use crate::text_utils::sanitize_for_speech; @@ -25,6 +26,31 @@ pub use sapi::SapiBackend; pub use piper::PiperBackend; pub use silero::SileroBackend; +/// Play a WAV file synchronously via PowerShell SoundPlayer (Windows) or no-op +/// stub elsewhere. Shared by file-based TTS backends (Piper, Silero). +pub(crate) fn play_wav(path: &Path) { + play_wav_impl(path); +} + +#[cfg(target_os = "windows")] +fn play_wav_impl(path: &Path) { + let escaped = path.display().to_string().replace('\'', "''"); + let ps = format!( + "$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()", + escaped + ); + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +#[cfg(not(target_os = "windows"))] +fn play_wav_impl(path: &Path) { + log::info!("[TTS non-Windows stub] would play: {}", path.display()); +} + /// Options for a single `speak()` call. #[derive(Debug, Clone)] pub struct SpeakOpts { diff --git a/crates/jarvis-core/src/tts/piper.rs b/crates/jarvis-core/src/tts/piper.rs index b90e7a7..cef4730 100644 --- a/crates/jarvis-core/src/tts/piper.rs +++ b/crates/jarvis-core/src/tts/piper.rs @@ -106,7 +106,7 @@ fn synth_and_play(binary: &Path, voice: &Path, text: &str) { match child.wait() { Ok(status) if status.success() => { - play_wav(&wav_path); + super::play_wav(&wav_path); } Ok(status) => log::warn!("Piper exited with status {}", status), Err(e) => log::warn!("Piper wait failed: {}", e), @@ -115,25 +115,6 @@ fn synth_and_play(binary: &Path, voice: &Path, text: &str) { let _ = std::fs::remove_file(&wav_path); } -#[cfg(target_os = "windows")] -fn play_wav(path: &Path) { - let escaped = path.display().to_string().replace('\'', "''"); - let ps = format!( - "$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()", - escaped - ); - let _ = std::process::Command::new("powershell") - .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); -} - -#[cfg(not(target_os = "windows"))] -fn play_wav(path: &Path) { - log::info!("[Piper non-Windows stub] would play: {}", path.display()); -} - fn find_binary() -> Option { if let Ok(p) = std::env::var("JARVIS_TTS_PIPER_BIN") { let candidate = PathBuf::from(p); diff --git a/crates/jarvis-core/src/tts/silero.rs b/crates/jarvis-core/src/tts/silero.rs index 5555918..1f4d8f3 100644 --- a/crates/jarvis-core/src/tts/silero.rs +++ b/crates/jarvis-core/src/tts/silero.rs @@ -107,29 +107,10 @@ fn synth_and_play(python: &str, helper: &Path, voice: &str, text: &str) { } let p = PathBuf::from(wav_path); - play_wav(&p); + super::play_wav(&p); let _ = std::fs::remove_file(&p); } -#[cfg(target_os = "windows")] -fn play_wav(path: &Path) { - let escaped = path.display().to_string().replace('\'', "''"); - let ps = format!( - "$p = New-Object System.Media.SoundPlayer '{}'; $p.PlaySync()", - escaped - ); - let _ = std::process::Command::new("powershell") - .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); -} - -#[cfg(not(target_os = "windows"))] -fn play_wav(path: &Path) { - log::info!("[Silero non-Windows stub] would play: {}", path.display()); -} - fn find_helper() -> Option { if let Ok(p) = std::env::var("JARVIS_TTS_SILERO_HELPER") { let candidate = PathBuf::from(p); diff --git a/resources/commands/daily_briefing/off.lua b/resources/commands/daily_briefing/off.lua index 8d479ba..c4e2c6e 100644 --- a/resources/commands/daily_briefing/off.lua +++ b/resources/commands/daily_briefing/off.lua @@ -1,9 +1,4 @@ -local removed = jarvis.scheduler.remove("daily_briefing_main") -if removed then - jarvis.speak("Утренний брифинг отключен.") - jarvis.audio.play_ok() -else - jarvis.speak("Брифинг и так не был настроен.") - jarvis.audio.play_not_found() +if jarvis.scheduler.remove("daily_briefing_main") then + return jarvis.cmd.ok("Утренний брифинг отключен.") end -return { chain = false } +return jarvis.cmd.not_found("Брифинг и так не был настроен.") diff --git a/resources/commands/habit_nudge/stop_all.lua b/resources/commands/habit_nudge/stop_all.lua index 31da84a..b8a4565 100644 --- a/resources/commands/habit_nudge/stop_all.lua +++ b/resources/commands/habit_nudge/stop_all.lua @@ -4,11 +4,8 @@ for _, id in ipairs({ "habit_water", "habit_stretch", "habit_eyes" }) do removed = removed + 1 end end + if removed > 0 then - jarvis.speak("Отключил привычек: " .. removed .. ".") - jarvis.audio.play_ok() -else - jarvis.speak("Привычки и так не запущены.") - jarvis.audio.play_not_found() + return jarvis.cmd.ok("Отключил привычек: " .. removed .. ".") end -return { chain = false } +return jarvis.cmd.not_found("Привычки и так не запущены.") diff --git a/resources/commands/memory_pack/list.lua b/resources/commands/memory_pack/list.lua index 8633c4a..302dc5c 100644 --- a/resources/commands/memory_pack/list.lua +++ b/resources/commands/memory_pack/list.lua @@ -1,8 +1,6 @@ local recs = jarvis.memory.all() if #recs == 0 then - jarvis.speak("Я пока ничего о вас не запомнил.") - jarvis.audio.play_ok() - return { chain = false } + return jarvis.cmd.ok("Я пока ничего о вас не запомнил.") end local count = #recs @@ -14,6 +12,4 @@ for i = 1, sample do end line = line .. "." -jarvis.speak(line) -jarvis.audio.play_ok() -return { chain = false } +return jarvis.cmd.ok(line) diff --git a/resources/commands/mood_log/command.toml b/resources/commands/mood_log/command.toml new file mode 100644 index 0000000..51666b3 --- /dev/null +++ b/resources/commands/mood_log/command.toml @@ -0,0 +1,39 @@ +# 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"] +ua = ["запиши настрій", "мій настрій"] + + +[[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"] +ua = ["підсумок настрою"] 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/pomodoro/stop.lua b/resources/commands/pomodoro/stop.lua index cec6e38..8ff7382 100644 --- a/resources/commands/pomodoro/stop.lua +++ b/resources/commands/pomodoro/stop.lua @@ -3,10 +3,6 @@ local removed_b = jarvis.scheduler.remove("pomodoro_break") jarvis.state.set("pomodoro_phase", "") if removed_w or removed_b then - jarvis.speak("Помодоро остановлен.") - jarvis.audio.play_ok() -else - jarvis.speak("Помодоро и так не запущен.") - jarvis.audio.play_not_found() + return jarvis.cmd.ok("Помодоро остановлен.") end -return { chain = false } +return jarvis.cmd.not_found("Помодоро и так не запущен.") diff --git a/resources/commands/scheduler/clear.lua b/resources/commands/scheduler/clear.lua index 9ff3cd6..3e8796a 100644 --- a/resources/commands/scheduler/clear.lua +++ b/resources/commands/scheduler/clear.lua @@ -1,8 +1,5 @@ local n = jarvis.scheduler.clear() if n == 0 then - jarvis.speak("В расписании и так пусто.") -else - jarvis.speak(string.format("Удалил %d задач.", n)) + return jarvis.cmd.ok("В расписании и так пусто.") end -jarvis.audio.play_ok() -return { chain = false } +return jarvis.cmd.ok(string.format("Удалил %d задач.", n))