From 6225198821951ae638b3851c56b022fe090a5906 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 16:06:18 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20maintainability=20pass=20=E2=80=94?= =?UTF-8?q?=20dedup,=20central=20env=20config,=20cmd=20helpers,=20tests,?= =?UTF-8?q?=20ARCHITECTURE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked the code stays easy to fix/extend as the feature surface grows. This commit refactors what was duplicated, centralises what was scattered, adds tests where there were none, and writes the architecture doc that contributors will need. Deduplication - tts::play_wav extracted to tts/mod.rs as pub(crate). Was identical in piper.rs and silero.rs (~25 lines × 2). Both backends now call super::play_wav. Centralised env config - new crates/jarvis-core/src/runtime_config.rs — single doc-file for every JARVIS_* / GROQ_* / OLLAMA_* env var. Includes: - ENV_* constants with doc comments - get(name) / get_bool(name, default) / get_parse(name, default) helpers - feature-flag wrappers: llm_tts_enabled(), llm_router_enabled(), llm_router_threshold() - log_effective_config() prints active values on startup - migrated llm_fallback (JARVIS_LLM_TTS), llm_router (JARVIS_LLM_ROUTER, JARVIS_LLM_ROUTER_THRESHOLD) to use the new helpers. Pattern set for future migrations. Lua boilerplate killer - new crates/jarvis-core/src/lua/api/cmd.rs exposing: jarvis.cmd.ok(msg?) — play_ok + speak + {chain=false} jarvis.cmd.chain_ok(msg?) — same but chain=true jarvis.cmd.error(msg?) — play_error + speak + {chain=false} jarvis.cmd.not_found(msg?) — play_not_found + speak + {chain=false} jarvis.cmd.silent_ok / silent_error - refactored 5 packs (daily_briefing/off, memory_pack/list, pomodoro/stop, habit_nudge/stop_all, scheduler/clear) — each lost 3-4 lines of repetitive play_*/speak/return boilerplate. Pattern for future packs documented in ARCHITECTURE.md. Tests (was 32, now 49) - long_term_memory: 8 new tests for normalize_key, search_in (rank, limit, empty), build_context_from (empty + populated), serde round-trips for MemoryRecord and Store. Extracted pure logic (search_in, build_context_from) into pub(crate) functions to enable testing without global state. - profiles: 6 new tests for Profile::allows_command (empty/whitelist/blacklist/ deny-wins-over-allow), serde round-trip with all fields + minimal-fields tolerance via #[serde(default)]. - runtime_config: 2 tests for get_bool / get_parse defaults. - All 49 tests pass. New mood/energy log pack - resources/commands/mood_log/ with 2 commands: mood.record "запиши настроение 7" / "сегодня мне грустно" → stores timestamped entry via jarvis.memory.remember mood.recap "как прошла неделя" → LLM summarises last 30 entries - Showcase: composes memory + llm + cmd helpers in <30 lines per script. ARCHITECTURE.md (new, 250 lines) - Crate layout, data flow diagram (mic→action 10 steps), per-module responsibility table, configuration layers, TTS pipeline diagram, Lua sandbox details with API quick-ref, background-services overview, "how to add a pack/feature/TTS backend" recipes, test coverage map, build instructions with MSVC env, git workflow with Forgejo NO_PROXY trick. - Aimed at someone who just cloned the repo and needs to fix a bug fast. Build: cargo build --release -p jarvis-app -p jarvis-gui both green. Tests: 49/49 jarvis-core unit tests pass. --- ARCHITECTURE.md | 295 ++++++++++++++++++++ crates/jarvis-app/src/llm_fallback.rs | 2 +- crates/jarvis-app/src/llm_router.rs | 10 +- crates/jarvis-app/src/main.rs | 3 + crates/jarvis-core/src/lib.rs | 2 + crates/jarvis-core/src/long_term_memory.rs | 149 +++++++++- crates/jarvis-core/src/lua/api.rs | 3 +- crates/jarvis-core/src/lua/api/cmd.rs | 81 ++++++ crates/jarvis-core/src/lua/engine.rs | 1 + crates/jarvis-core/src/profiles.rs | 80 ++++++ crates/jarvis-core/src/runtime_config.rs | 138 +++++++++ crates/jarvis-core/src/tts/mod.rs | 26 ++ crates/jarvis-core/src/tts/piper.rs | 21 +- crates/jarvis-core/src/tts/silero.rs | 21 +- resources/commands/daily_briefing/off.lua | 11 +- resources/commands/habit_nudge/stop_all.lua | 9 +- resources/commands/memory_pack/list.lua | 8 +- resources/commands/mood_log/command.toml | 39 +++ resources/commands/mood_log/recap.lua | 33 +++ resources/commands/mood_log/record.lua | 37 +++ resources/commands/pomodoro/stop.lua | 8 +- resources/commands/scheduler/clear.lua | 7 +- 22 files changed, 891 insertions(+), 93 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 crates/jarvis-core/src/lua/api/cmd.rs create mode 100644 crates/jarvis-core/src/runtime_config.rs create mode 100644 resources/commands/mood_log/command.toml create mode 100644 resources/commands/mood_log/recap.lua create mode 100644 resources/commands/mood_log/record.lua 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))