From 45243c3e3ca1df5e944a1d4119194afa87a15c25 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 15:51:24 +0300 Subject: [PATCH] feat: Ollama LLM backend + 5 utility packs (daily briefing, pomodoro, currency convert, stocks, habits, translate clipboard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-LLM support so J.A.R.V.I.S. works offline without GROQ_TOKEN. LLM multi-backend (crates/jarvis-core/src/llm/client.rs) - LlmBackend enum {Groq, Ollama}. Both use OpenAI-compatible /v1/chat/completions (Ollama exposes one natively at :11434/v1). - LlmClient::from_env() now dispatches on JARVIS_LLM=groq|ollama. Auto-detect: Groq if GROQ_TOKEN set, else Ollama. - New helpers: LlmClient::groq() (was: from_env), LlmClient::ollama(). - Reqwest client now has a 60s timeout (was: unbounded — Groq freezes could hang). - api_key skipped when empty (Ollama doesn't need auth). - Defaults: OLLAMA_BASE_URL=http://localhost:11434/v1, OLLAMA_MODEL=qwen2.5:3b. Override via OLLAMA_BASE_URL / OLLAMA_MODEL env. - llm_fallback + llm_router log the active backend on init. - Tests: 32/32 pass. Renamed `from_env_fails` → `groq_fails_when_token_missing`, added `ollama_works_without_token`. Daily briefing pack (resources/commands/daily_briefing/, 3 commands) - setup.lua: "настрой утренний брифинг на 9:00" → adds a daily scheduled task that runs now.lua via the scheduler's Lua-action support. - now.lua: greeting + time + active profile + scheduled task count + memory recap + LLM motivational nudge. Voice-triggered ("утренний брифинг") works the same. - off.lua: removes the scheduled task. Pomodoro pack (resources/commands/pomodoro/, 2 commands) - start.lua / stop.lua + tick.lua (re-scheduled by itself). State stored in jarvis.state — phase alternates work (25 min) / break (5 min) until stop. Currency conversion (resources/commands/currency/convert.lua) - "сколько будет 1000 долларов в рублях" — fetches CBR daily rates from cbr-xml-daily.ru, normalises by Nominal, prints both directions. - Recognises USD/EUR/CNY/GBP/JPY/RUB by substring. Pluralises russian units. Stocks pack (resources/commands/stocks/, 1 command) - "сколько Сбер" → MOEX ISS /iss/engines/stock/markets/shares/securities/. - Mapping of 22 popular Russian tickers to common names (Сбер→SBER, Газпром→GAZP, Лукойл→LKOH, Яндекс→YDEX, Т-банк→T, etc). - Picks TQBR (main board) row, reports LAST + LASTTOPREVPRICE%. Habit nudges (resources/commands/habit_nudge/, 4 commands) - "напоминай мне пить воду" → every 2 hours - "напоминай мне размяться" → every 50 minutes - "напоминай отдыхать глазам" → every 20 minutes (20-20-20 rule) - "отключи все привычки" → stops all three by id. Translate clipboard (resources/commands/translate/clipboard.lua) - "переведи буфер на английский" → grabs clipboard, sends to LLM, speaks first sentence, replaces clipboard with full translation, fires notification. Build: cargo build --release -p jarvis-app -p jarvis-gui both green. 32/32 tests. To use Ollama: 1. Install + run Ollama (https://ollama.com). 2. `ollama pull qwen2.5:3b` (or any chat model). 3. Optional: `set JARVIS_LLM=ollama` (auto-picked if GROQ_TOKEN unset). --- crates/jarvis-app/src/llm_fallback.rs | 4 +- crates/jarvis-app/src/llm_router.rs | 4 +- crates/jarvis-core/src/llm/client.rs | 100 +++++++++++++-- resources/commands/currency/command.toml | 33 +++++ resources/commands/currency/convert.lua | 121 ++++++++++++++++++ .../commands/daily_briefing/command.toml | 54 ++++++++ resources/commands/daily_briefing/now.lua | 64 +++++++++ resources/commands/daily_briefing/off.lua | 9 ++ resources/commands/daily_briefing/setup.lua | 38 ++++++ resources/commands/habit_nudge/command.toml | 69 ++++++++++ resources/commands/habit_nudge/eyes.lua | 18 +++ resources/commands/habit_nudge/stop_all.lua | 14 ++ resources/commands/habit_nudge/stretch.lua | 17 +++ resources/commands/habit_nudge/water.lua | 17 +++ resources/commands/pomodoro/command.toml | 37 ++++++ resources/commands/pomodoro/start.lua | 37 ++++++ resources/commands/pomodoro/stop.lua | 12 ++ resources/commands/pomodoro/tick.lua | 35 +++++ resources/commands/stocks/command.toml | 35 +++++ resources/commands/stocks/quote.lua | 119 +++++++++++++++++ resources/commands/translate/clipboard.lua | 49 +++++++ resources/commands/translate/command.toml | 25 ++++ 22 files changed, 895 insertions(+), 16 deletions(-) create mode 100644 resources/commands/currency/convert.lua create mode 100644 resources/commands/daily_briefing/command.toml create mode 100644 resources/commands/daily_briefing/now.lua create mode 100644 resources/commands/daily_briefing/off.lua create mode 100644 resources/commands/daily_briefing/setup.lua create mode 100644 resources/commands/habit_nudge/command.toml create mode 100644 resources/commands/habit_nudge/eyes.lua create mode 100644 resources/commands/habit_nudge/stop_all.lua create mode 100644 resources/commands/habit_nudge/stretch.lua create mode 100644 resources/commands/habit_nudge/water.lua create mode 100644 resources/commands/pomodoro/command.toml create mode 100644 resources/commands/pomodoro/start.lua create mode 100644 resources/commands/pomodoro/stop.lua create mode 100644 resources/commands/pomodoro/tick.lua create mode 100644 resources/commands/stocks/command.toml create mode 100644 resources/commands/stocks/quote.lua create mode 100644 resources/commands/translate/clipboard.lua diff --git a/crates/jarvis-app/src/llm_fallback.rs b/crates/jarvis-app/src/llm_fallback.rs index 7e1f1ab..bbfc4f8 100644 --- a/crates/jarvis-app/src/llm_fallback.rs +++ b/crates/jarvis-app/src/llm_fallback.rs @@ -31,7 +31,7 @@ fn build_state() -> Option { let client = match LlmClient::from_env() { Ok(c) => c, Err(e) => { - warn!("LLM fallback disabled: {}. Set GROQ_TOKEN to enable.", e); + warn!("LLM fallback disabled: {}. Set GROQ_TOKEN or run Ollama to enable.", e); return None; } }; @@ -40,7 +40,7 @@ fn build_state() -> Option { let prompt = config::get_llm_system_prompt(&lang); let history = Mutex::new(ConversationHistory::new(prompt, config::LLM_DEFAULT_MAX_HISTORY)); - info!("LLM fallback enabled (model: {}).", client.model()); + info!("LLM fallback enabled (backend: {}, model: {}).", client.backend().name(), client.model()); Some(State { client, history, diff --git a/crates/jarvis-app/src/llm_router.rs b/crates/jarvis-app/src/llm_router.rs index fd43956..0f17883 100644 --- a/crates/jarvis-app/src/llm_router.rs +++ b/crates/jarvis-app/src/llm_router.rs @@ -56,7 +56,7 @@ fn build_state() -> Option { let client = match LlmClient::from_env() { Ok(c) => c, Err(e) => { - warn!("LLM router disabled: {}. Set GROQ_TOKEN to enable.", e); + warn!("LLM router disabled: {}. Set GROQ_TOKEN or run Ollama to enable.", e); return None; } }; @@ -65,7 +65,7 @@ fn build_state() -> Option { .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_THRESHOLD); - info!("LLM router enabled (threshold {:.2}).", threshold); + info!("LLM router enabled (backend: {}, threshold {:.2}).", client.backend().name(), threshold); Some(State { client, threshold }) } diff --git a/crates/jarvis-core/src/llm/client.rs b/crates/jarvis-core/src/llm/client.rs index 1f92134..8861e0d 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 { @@ -122,12 +192,11 @@ impl LlmClient { 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()?; @@ -173,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, @@ -189,6 +258,13 @@ 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"); diff --git a/resources/commands/currency/command.toml b/resources/commands/currency/command.toml index 1c4ce85..29dd084 100644 --- a/resources/commands/currency/command.toml +++ b/resources/commands/currency/command.toml @@ -27,3 +27,36 @@ ua = [ "курс євро", "що з доларом", ] + + +# 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", +] +ua = [ + "переведи", + "конвертуй", +] 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/daily_briefing/command.toml b/resources/commands/daily_briefing/command.toml new file mode 100644 index 0000000..fc2d35b --- /dev/null +++ b/resources/commands/daily_briefing/command.toml @@ -0,0 +1,54 @@ +# 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"] +ua = ["налаштуй ранковий брифінг", "увімкни ранковий брифінг"] + + +[[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"] +ua = ["ранковий брифінг", "брифінг зараз"] + + +[[commands]] +id = "daily_briefing.off" +type = "lua" +script = "off.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "отключи утренний брифинг", + "выключи утренний брифинг", + "отмени брифинг", +] +en = ["disable morning briefing", "cancel morning briefing"] +ua = ["вимкни ранковий брифінг"] 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..8d479ba --- /dev/null +++ b/resources/commands/daily_briefing/off.lua @@ -0,0 +1,9 @@ +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() +end +return { chain = false } 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/habit_nudge/command.toml b/resources/commands/habit_nudge/command.toml new file mode 100644 index 0000000..fe227f7 --- /dev/null +++ b/resources/commands/habit_nudge/command.toml @@ -0,0 +1,69 @@ +# 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"] +ua = ["нагадуй мені пити воду"] + + +[[commands]] +id = "habit.stretch" +type = "lua" +script = "stretch.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "напоминай мне размяться", + "напоминай делать зарядку", + "включи напоминание про разминку", + "напоминай вставать", +] +en = ["remind me to stretch", "stretch reminder"] +ua = ["нагадуй мені розім'ятися"] + + +[[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"] +ua = ["нагадуй про очі"] + + +[[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"] +ua = ["вимкни всі звички"] 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..31da84a --- /dev/null +++ b/resources/commands/habit_nudge/stop_all.lua @@ -0,0 +1,14 @@ +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 + jarvis.speak("Отключил привычек: " .. removed .. ".") + jarvis.audio.play_ok() +else + jarvis.speak("Привычки и так не запущены.") + jarvis.audio.play_not_found() +end +return { chain = false } 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/pomodoro/command.toml b/resources/commands/pomodoro/command.toml new file mode 100644 index 0000000..71411c0 --- /dev/null +++ b/resources/commands/pomodoro/command.toml @@ -0,0 +1,37 @@ +# 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"] +ua = ["запусти помодоро", "увімкни помодоро"] + + +[[commands]] +id = "pomodoro.stop" +type = "lua" +script = "stop.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "останови помодоро", + "стоп помодоро", + "выключи помодоро", + "хватит помодоро", +] +en = ["stop pomodoro", "cancel pomodoro"] +ua = ["зупини помодоро", "вимкни помодоро"] 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..cec6e38 --- /dev/null +++ b/resources/commands/pomodoro/stop.lua @@ -0,0 +1,12 @@ +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 + jarvis.speak("Помодоро остановлен.") + jarvis.audio.play_ok() +else + jarvis.speak("Помодоро и так не запущен.") + jarvis.audio.play_not_found() +end +return { chain = false } 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/stocks/command.toml b/resources/commands/stocks/command.toml new file mode 100644 index 0000000..f82219b --- /dev/null +++ b/resources/commands/stocks/command.toml @@ -0,0 +1,35 @@ +# 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", +] +ua = [ + "ціна акцій", + "котирування", +] 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/translate/clipboard.lua b/resources/commands/translate/clipboard.lua new file mode 100644 index 0000000..a223692 --- /dev/null +++ b/resources/commands/translate/clipboard.lua @@ -0,0 +1,49 @@ +-- "Переведи буфер на английский" → 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("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 index e4fdda0..70241d9 100644 --- a/resources/commands/translate/command.toml +++ b/resources/commands/translate/command.toml @@ -32,3 +32,28 @@ ua = [ "переклади на російську", "переклади", ] + + +# 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", +] +ua = [ + "переклади буфер", +]