feat: Ollama LLM backend + 5 utility packs (daily briefing, pomodoro, currency convert, stocks, habits, translate clipboard)

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/<TKR>.
  - 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).
This commit is contained in:
Bossiara13 2026-05-15 15:51:24 +03:00
parent 12b1ed4ccb
commit 45243c3e3c
22 changed files with 895 additions and 16 deletions

View file

@ -31,7 +31,7 @@ fn build_state() -> Option<State> {
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<State> {
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,

View file

@ -56,7 +56,7 @@ fn build_state() -> Option<State> {
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<State> {
.and_then(|v| v.parse::<f32>().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 })
}

View file

@ -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<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
Self::new_with_backend(LlmBackend::Groq, base_url, api_key, model)
}
pub fn new_with_backend(
backend: LlmBackend,
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> 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<Self, ConfigError> {
pub fn groq() -> Result<Self, ConfigError> {
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<Self, ConfigError> {
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");

View file

@ -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 = [
"переведи",
"конвертуй",
]

View file

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

View file

@ -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 = ["вимкни ранковий брифінг"]

View file

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

View file

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

View file

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

View file

@ -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 = ["вимкни всі звички"]

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = ["зупини помодоро", "вимкни помодоро"]

View file

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

View file

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

View file

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

View file

@ -0,0 +1,35 @@
# Stocks — query Moscow Exchange (MOEX) for share price.
# API: https://iss.moex.com/iss/engines/stock/markets/shares/securities/<TICKER>.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 = [
"ціна акцій",
"котирування",
]

View file

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

View file

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

View file

@ -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 = [
"переклади буфер",
]