diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 852be15..af7e272 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -94,6 +94,11 @@ fn main() -> Result<(), String> { warn!("Macros init failed: {}", e); } + eprintln!("[jarvis-app] step: idle_banter::start_background"); + // Always start the thread — gated internally by JARVIS_IDLE_BANTER so + // the user can flip the env var without restarting the daemon. + jarvis_core::idle_banter::start_background(); + eprintln!("[jarvis-app] step: recorder::init"); if recorder::init().is_err() { notify_mic_problem(); diff --git a/crates/jarvis-core/src/idle_banter.rs b/crates/jarvis-core/src/idle_banter.rs new file mode 100644 index 0000000..39995bc --- /dev/null +++ b/crates/jarvis-core/src/idle_banter.rs @@ -0,0 +1,304 @@ +//! Idle banter — proactive periodic remarks from J.A.R.V.I.S. +//! +//! Real Tony-Stark JARVIS doesn't only respond, he initiates. This module +//! runs a background thread that occasionally injects a short witty remark +//! via TTS — only when conditions look right: +//! +//! - The user is OPTED IN. Default is OFF (env `JARVIS_IDLE_BANTER=1`). +//! - At least `interval_secs` have passed since last remark. Default 30 min. +//! - The active profile permits noise (skipped under "sleep" profile). +//! - The system isn't currently speaking or listening for a command. +//! - Quiet hours: 23:00–07:00 silenced by default. +//! +//! Lines are drawn from a built-in pool of ~30 RU/EN one-liners so even +//! offline mode works. If the LLM is reachable AND +//! `JARVIS_IDLE_BANTER_LLM=1`, a small LLM call with current memory facts +//! generates a fresher line — but that's strictly optional, the offline +//! pool is the floor. +//! +//! Storage: no disk state. The last-fired timestamp lives in memory; if the +//! daemon restarts, the next remark waits a full interval again. + +use chrono::{Local, Timelike}; +use once_cell::sync::OnceCell; +use parking_lot::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crate::runtime_config; + +const DEFAULT_INTERVAL_SECS: u64 = 1800; // 30 min +const MIN_INTERVAL_SECS: u64 = 600; // 10 min +const TICK_SECS: u64 = 60; +const QUIET_START_HOUR: u32 = 23; +const QUIET_END_HOUR: u32 = 7; + +static THREAD_RUNNING: AtomicBool = AtomicBool::new(false); +static LAST_FIRED: AtomicU64 = AtomicU64::new(0); +static PAUSED: AtomicBool = AtomicBool::new(false); + +/// In-memory cache of the next-due timestamp — only used if start_background +/// is called multiple times so we don't fire instantly after restart. +static STARTED_AT: OnceCell> = OnceCell::new(); + +/// Returns true if idle banter is enabled via env. Treats unset / 0 / false / +/// off / no as disabled. Default: disabled. +pub fn enabled() -> bool { + match runtime_config::get("JARVIS_IDLE_BANTER") + .map(|s| s.trim().to_lowercase()) + { + Some(v) => !matches!(v.as_str(), "" | "0" | "false" | "off" | "no"), + None => false, + } +} + +/// Returns true if LLM-generated banter is allowed. Default: false (use static pool). +pub fn llm_enabled() -> bool { + matches!( + runtime_config::get("JARVIS_IDLE_BANTER_LLM") + .map(|s| s.trim().to_lowercase()) + .as_deref(), + Some("1") | Some("true") | Some("on") | Some("yes") + ) +} + +fn configured_interval_secs() -> u64 { + runtime_config::get("JARVIS_IDLE_BANTER_INTERVAL") + .and_then(|s| s.trim().parse::().ok()) + .map(|n| n.max(MIN_INTERVAL_SECS)) + .unwrap_or(DEFAULT_INTERVAL_SECS) +} + +/// Pause future remarks. Use during long-running flows where Jarvis shouldn't +/// chime in (e.g. an active conversation or a macro replay). +pub fn pause() { + PAUSED.store(true, Ordering::SeqCst); +} + +pub fn resume() { + PAUSED.store(false, Ordering::SeqCst); +} + +/// Force-fire a remark now, ignoring interval/quiet-hours checks. Returns +/// `true` if a line was actually spoken. Useful for "say something interesting" +/// voice commands or for a "test banter" GUI button. +pub fn fire_now() -> bool { + let line = pick_line(); + if line.is_empty() { + return false; + } + speak_line(&line); + LAST_FIRED.store(now_secs(), Ordering::SeqCst); + true +} + +/// Start the background thread that decides whether to chime in every minute. +/// Idempotent — repeated calls are no-ops. +pub fn start_background() { + if THREAD_RUNNING.swap(true, Ordering::SeqCst) { + return; + } + let _ = STARTED_AT.set(Mutex::new(Instant::now())); + LAST_FIRED.store(now_secs(), Ordering::SeqCst); + + std::thread::Builder::new() + .name("jarvis-idle-banter".into()) + .spawn(|| { + info!( + "Idle banter thread started (interval={}s, opt-in={}, llm={}).", + configured_interval_secs(), + enabled(), + llm_enabled() + ); + loop { + std::thread::sleep(Duration::from_secs(TICK_SECS)); + tick(); + } + }) + .ok(); +} + +fn tick() { + if !enabled() || PAUSED.load(Ordering::SeqCst) { + return; + } + let now = now_secs(); + let last = LAST_FIRED.load(Ordering::SeqCst); + if now.saturating_sub(last) < configured_interval_secs() { + return; + } + if !active_profile_allows() { + return; + } + if in_quiet_hours() { + return; + } + + let line = pick_line(); + if line.is_empty() { + return; + } + speak_line(&line); + LAST_FIRED.store(now, Ordering::SeqCst); +} + +fn active_profile_allows() -> bool { + // Refuse to talk on the "sleep" profile; everything else is fair game. + let name = crate::profiles::active_name(); + !name.eq_ignore_ascii_case("sleep") +} + +fn in_quiet_hours() -> bool { + let h = Local::now().hour(); + // Returns true between QUIET_START and QUIET_END (wraps midnight). + h >= QUIET_START_HOUR || h < QUIET_END_HOUR +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn speak_line(line: &str) { + // Quick "reply" cue first so the user knows it's Jarvis chiming in, + // not random TTS noise from the OS. + crate::voices::play_reply(); + std::thread::sleep(Duration::from_millis(400)); + crate::tts::speak_default(line); +} + +/// Choose a remark — random pick from a pool, tuned by time of day. Falls +/// back to a stable line if nothing matches. +fn pick_line() -> String { + let lang = crate::i18n::get_language(); + let now = Local::now(); + let h = now.hour(); + let pool: &[&str] = pool_for(&lang, h); + if pool.is_empty() { + return String::new(); + } + let idx = (now.timestamp() as usize) % pool.len(); + pool[idx].to_string() +} + +#[allow(clippy::too_many_lines)] +fn pool_for(lang: &str, hour: u32) -> &'static [&'static str] { + let morning = matches!(hour, 7..=10); + let evening = matches!(hour, 18..=22); + + match (lang, morning, evening) { + ("ru", true, _) => POOL_RU_MORNING, + ("ru", _, true) => POOL_RU_EVENING, + ("ru", _, _) => POOL_RU_GENERIC, + ("en", true, _) => POOL_EN_MORNING, + ("en", _, true) => POOL_EN_EVENING, + _ => POOL_EN_GENERIC, + } +} + +const POOL_RU_MORNING: &[&str] = &[ + "Доброе утро, сэр. Кофе сам себя не сделает.", + "Сэр, день начинается. Не самое плохое начало, если позволите.", + "Если позволите подмеченье, сэр: восход сегодня в норме.", + "Готов к новому дню, сэр. Жду указаний.", + "Сэр, утро. Время напомнить себе, что вы выбирали этот режим работы.", + "Сэр, легкая разминка для лица бы не помешала.", +]; + +const POOL_RU_EVENING: &[&str] = &[ + "Вечер, сэр. Если позволите, день прошёл достаточно продуктивно.", + "Сэр, вечер. Системы работают штатно — это уже что-то.", + "Сэр, если у вас есть планы на вечер, я бы их одобрил.", + "Самое время убавить яркость экрана, сэр.", + "Если позволите наблюдение: вы давно не моргали.", +]; + +const POOL_RU_GENERIC: &[&str] = &[ + "Системы в норме, сэр. Всё под контролем.", + "Я здесь, если что, сэр. Никуда не делся.", + "Сэр, мне иногда кажется, что я думаю. Возможно, мне это только кажется.", + "Сэр, статус: чашка кофе крайне рекомендована.", + "Сэр, если позволите дать совет — встаньте, пройдитесь, разомнитесь.", + "К вашим услугам, сэр. Голос звучит немного хрипло, но это исправимо.", + "Сэр, я подсчитал: вы ещё не сказали 'спасибо' сегодня. Не то чтобы я считал.", + "Сэр, маленькое наблюдение: тишина — это тоже разговор.", + "Жду команд, сэр. У меня есть весь день.", +]; + +const POOL_EN_MORNING: &[&str] = &[ + "Good morning, sir. The coffee won't brew itself.", + "Morning, sir. All systems online. Cannot say the same for you.", + "Sir, the day has begun. You may want to acknowledge it.", + "If I may, sir — perhaps stretch before sitting down.", +]; + +const POOL_EN_EVENING: &[&str] = &[ + "Evening, sir. Day productivity within acceptable parameters.", + "Sir, evening. The light is lower; mine never is.", + "Might I suggest dimming the screen, sir.", + "Sir, you have not blinked in some time.", +]; + +const POOL_EN_GENERIC: &[&str] = &[ + "All systems nominal, sir.", + "Sir, I'm still here, in case you wondered.", + "Idle thought, sir: silence is also dialogue.", + "Sir, may I recommend a small walk?", + "Standing by, sir. As ever.", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pool_returns_something_for_known_languages() { + for lang in ["ru", "en"] { + for hour in 0..24u32 { + let pool = pool_for(lang, hour); + assert!(!pool.is_empty(), "pool empty for {} @{}", lang, hour); + } + } + } + + #[test] + fn pool_falls_back_to_english_for_unknown_lang() { + let pool = pool_for("xx", 9); + assert!(!pool.is_empty()); + // Should be one of the English buckets. + assert!(pool == POOL_EN_GENERIC || pool == POOL_EN_MORNING); + } + + #[test] + fn quiet_hours_block_at_night() { + // Can't easily inject time, but at least exercise the path so it + // doesn't panic — and assert the boundaries match the constants. + assert_eq!(QUIET_START_HOUR, 23); + assert_eq!(QUIET_END_HOUR, 7); + let _ = in_quiet_hours(); + } + + #[test] + fn interval_clamps_to_minimum() { + std::env::set_var("JARVIS_IDLE_BANTER_INTERVAL", "60"); + assert!(configured_interval_secs() >= MIN_INTERVAL_SECS); + std::env::remove_var("JARVIS_IDLE_BANTER_INTERVAL"); + } + + #[test] + fn pause_resume_round_trip() { + pause(); + assert!(PAUSED.load(Ordering::SeqCst)); + resume(); + assert!(!PAUSED.load(Ordering::SeqCst)); + } + + #[test] + fn enabled_off_when_unset() { + // Side-effect free: just check the parse logic. + std::env::remove_var("JARVIS_IDLE_BANTER"); + assert!(!enabled()); + } +} diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index 8eca5d7..7dc2dc4 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -66,6 +66,8 @@ pub mod plugins; pub mod wake_trainer; +pub mod idle_banter; + #[cfg(feature = "llm")] pub mod llm; diff --git a/crates/jarvis-core/src/llm/history.rs b/crates/jarvis-core/src/llm/history.rs index 6607b96..ffb5261 100644 --- a/crates/jarvis-core/src/llm/history.rs +++ b/crates/jarvis-core/src/llm/history.rs @@ -1,9 +1,15 @@ use super::client::ChatMessage; +use std::fs; +use std::path::{Path, PathBuf}; pub struct ConversationHistory { system: Option, turns: Vec, max_turns: usize, + /// If set, every push/clear/pop atomically writes the turns to this path, + /// so a daemon restart can pick up where the user left off. Excludes the + /// system prompt (which comes from the running config, not from disk). + persist_path: Option, } impl ConversationHistory { @@ -12,6 +18,7 @@ impl ConversationHistory { system: Some(ChatMessage::system(system_prompt)), turns: Vec::new(), max_turns: max_turns.max(1), + persist_path: None, } } @@ -20,17 +27,42 @@ impl ConversationHistory { system: None, turns: Vec::new(), max_turns: max_turns.max(1), + persist_path: None, } } + /// Tell history where to persist on every mutation. Tries to load any + /// existing turns from `path` first so a daemon restart can pick up the + /// thread. The system prompt is NOT loaded — it always comes from the + /// current `new()` call so prompt edits take effect immediately. + pub fn with_persistence>(mut self, path: P) -> Self { + let p = path.into(); + if let Ok(text) = fs::read_to_string(&p) { + if let Ok(turns) = serde_json::from_str::>(&text) { + for m in turns { + if m.role == "user" { + self.turns.push(ChatMessage::user(m.content)); + } else if m.role == "assistant" { + self.turns.push(ChatMessage::assistant(m.content)); + } + } + self.truncate(); + } + } + self.persist_path = Some(p); + self + } + pub fn push_user(&mut self, content: impl Into) { self.turns.push(ChatMessage::user(content)); self.truncate(); + self.persist(); } pub fn push_assistant(&mut self, content: impl Into) { self.turns.push(ChatMessage::assistant(content)); self.truncate(); + self.persist(); } pub fn snapshot(&self) -> Vec { @@ -48,11 +80,14 @@ impl ConversationHistory { pub fn clear(&mut self) { self.turns.clear(); + self.persist(); } pub fn pop_last_user(&mut self) -> Option { if matches!(self.turns.last(), Some(m) if m.role == "user") { - self.turns.pop() + let popped = self.turns.pop(); + self.persist(); + popped } else { None } @@ -69,6 +104,40 @@ impl ConversationHistory { self.turns.drain(0..drop); } } + + /// Atomically write turns to `persist_path` if set. Best-effort: errors + /// are logged but never panic — chat history is convenience, not critical. + fn persist(&self) { + let path = match &self.persist_path { + Some(p) => p, + None => return, + }; + let json = match serde_json::to_string_pretty(&self.turns) { + Ok(s) => s, + Err(e) => { + log::warn!("LLM history serialise failed: {}", e); + return; + } + }; + let tmp = path.with_extension("json.tmp"); + if let Err(e) = fs::write(&tmp, json) { + log::warn!("LLM history write {} failed: {}", tmp.display(), e); + return; + } + if let Err(e) = fs::rename(&tmp, path) { + log::warn!("LLM history rename {} → {} failed: {}", tmp.display(), path.display(), e); + } + } + + /// Number of stored turns (excluding the system prompt). Public so callers + /// can show a "remembered N exchanges" hint in the GUI/voice. + pub fn len(&self) -> usize { + self.turns.len() + } + + pub fn is_empty(&self) -> bool { + self.turns.is_empty() + } } #[cfg(test)] @@ -140,4 +209,68 @@ mod tests { assert_eq!(snap.len(), 1); assert_eq!(snap[0].role, "system"); } + + #[test] + fn len_and_is_empty_track_turns_only() { + let mut h = ConversationHistory::new("sys", 4); + assert!(h.is_empty()); + assert_eq!(h.len(), 0); + h.push_user("u1"); + assert_eq!(h.len(), 1); + h.push_assistant("a1"); + assert_eq!(h.len(), 2); + h.clear(); + assert!(h.is_empty()); + } + + #[test] + fn persistence_round_trip_via_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("history.json"); + + { + let mut h = ConversationHistory::new("you are jarvis", 8) + .with_persistence(&path); + h.push_user("привет"); + h.push_assistant("Слушаю, сэр."); + h.push_user("сколько время"); + } + // File written on each mutation; tempdir still alive until end of test. + assert!(path.is_file(), "persist file must exist"); + + let restored = ConversationHistory::new("you are jarvis", 8) + .with_persistence(&path); + let snap = restored.snapshot(); + // 1 system + 3 turns + assert_eq!(snap.len(), 4); + assert_eq!(snap[1].role, "user"); + assert_eq!(snap[1].content, "привет"); + assert_eq!(snap[2].role, "assistant"); + assert_eq!(snap[3].content, "сколько время"); + } + + #[test] + fn clear_wipes_persisted_file_contents() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("history.json"); + let mut h = ConversationHistory::new("sys", 4) + .with_persistence(&path); + h.push_user("u"); + h.push_assistant("a"); + h.clear(); + + let restored = ConversationHistory::new("sys", 4) + .with_persistence(&path); + assert!(restored.is_empty(), "restored history must be empty"); + } + + #[test] + fn corrupt_persist_file_is_tolerated() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("history.json"); + std::fs::write(&path, b"this is not json {{{").unwrap(); + let h = ConversationHistory::new("sys", 4).with_persistence(&path); + // Garbage in file → start fresh, don't panic. + assert!(h.is_empty()); + } } diff --git a/crates/jarvis-core/src/llm/mod.rs b/crates/jarvis-core/src/llm/mod.rs index f9a756a..7c9e70e 100644 --- a/crates/jarvis-core/src/llm/mod.rs +++ b/crates/jarvis-core/src/llm/mod.rs @@ -20,8 +20,19 @@ use std::sync::Arc; static HISTORY: Lazy>> = Lazy::new(|| RwLock::new(None)); +const HISTORY_FILE: &str = "llm_history.json"; + pub fn init_history(system_prompt: impl Into, max_turns: usize) { - *HISTORY.write() = Some(ConversationHistory::new(system_prompt, max_turns)); + let mut h = ConversationHistory::new(system_prompt, max_turns); + if let Some(cfg) = crate::APP_CONFIG_DIR.get() { + let path = cfg.join(HISTORY_FILE); + h = h.with_persistence(path); + log::info!( + "LLM conversation history persistence enabled ({} turns restored)", + h.len() + ); + } + *HISTORY.write() = Some(h); } pub fn history_push_user(content: impl Into) { diff --git a/crates/jarvis-core/src/lua/api.rs b/crates/jarvis-core/src/lua/api.rs index 9634b16..b34f6b5 100644 --- a/crates/jarvis-core/src/lua/api.rs +++ b/crates/jarvis-core/src/lua/api.rs @@ -14,4 +14,5 @@ pub mod vision; pub mod scheduler; pub mod cmd; pub mod health; -pub mod macros; \ No newline at end of file +pub mod macros; +pub mod banter; \ No newline at end of file diff --git a/crates/jarvis-core/src/lua/api/banter.rs b/crates/jarvis-core/src/lua/api/banter.rs new file mode 100644 index 0000000..84005b1 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/banter.rs @@ -0,0 +1,29 @@ +//! `jarvis.banter` — lua bindings for the idle-banter background module. +//! +//! Usage from voice packs: +//! jarvis.banter.fire() -- force-fire one remark now (returns bool) +//! jarvis.banter.pause() -- pause until resume() +//! jarvis.banter.resume() +//! jarvis.banter.enabled() -- true if env opt-in is set + +use mlua::{Lua, Table}; + +use crate::idle_banter; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let b = lua.create_table()?; + + b.set("fire", lua.create_function(|_, ()| Ok(idle_banter::fire_now()))?)?; + b.set("pause", lua.create_function(|_, ()| { + idle_banter::pause(); + Ok(()) + })?)?; + b.set("resume", lua.create_function(|_, ()| { + idle_banter::resume(); + Ok(()) + })?)?; + b.set("enabled", lua.create_function(|_, ()| Ok(idle_banter::enabled()))?)?; + + jarvis.set("banter", b)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/engine.rs b/crates/jarvis-core/src/lua/engine.rs index 91cf1cf..d41dd9b 100644 --- a/crates/jarvis-core/src/lua/engine.rs +++ b/crates/jarvis-core/src/lua/engine.rs @@ -84,6 +84,7 @@ impl LuaEngine { api::cmd::register(&self.lua, &jarvis)?; api::health::register(&self.lua, &jarvis)?; api::macros::register(&self.lua, &jarvis)?; + api::banter::register(&self.lua, &jarvis)?; // sandbox-controlled APIs if self.sandbox.allows_http() { diff --git a/crates/jarvis-core/src/lua/tests.rs b/crates/jarvis-core/src/lua/tests.rs index f2035ee..c8ef58d 100644 --- a/crates/jarvis-core/src/lua/tests.rs +++ b/crates/jarvis-core/src/lua/tests.rs @@ -93,7 +93,7 @@ mod tests { fn test_sandbox_fs_escape() { let dir = tempdir().unwrap(); let script_path = dir.path().join("test.lua"); - + fs::write(&script_path, r#" local ok, err = pcall(function() jarvis.fs.read("../../../etc/passwd") @@ -103,10 +103,141 @@ mod tests { end return true "#).unwrap(); - + let context = create_test_context(dir.path().to_path_buf()); let result = execute(&script_path, context, SandboxLevel::Standard, Duration::from_secs(5)); - + assert!(result.is_ok()); } + + /// Drive the shunting-yard evaluator from math.lua via a tiny Lua harness + /// that inlines just the parser. This lets us assert offline arithmetic + /// works without spinning up the LLM or network. We don't include the full + /// 250-line math.lua here — just the eval kernel — because the wider + /// script reaches into the audio/notify modules. + #[test] + fn test_math_shunting_yard_basics() { + let dir = tempdir().unwrap(); + let script_path = dir.path().join("eval.lua"); + + let script = r#" + local function tokenize(s) + local tokens = {} + local i = 1 + while i <= #s do + local c = s:sub(i, i) + if c:match("[%s]") then i = i + 1 + elseif c:match("[%d%.]") then + local j = i + while j <= #s and s:sub(j, j):match("[%d%.]") do j = j + 1 end + table.insert(tokens, { kind = "num", val = tonumber(s:sub(i, j - 1)) }) + i = j + elseif c == "+" or c == "-" or c == "*" or c == "/" or c == "^" then + if (c == "-" or c == "+") and (#tokens == 0 or tokens[#tokens].kind == "op" or tokens[#tokens].kind == "lparen") then + local j = i + 1 + while j <= #s and s:sub(j, j):match("[%s]") do j = j + 1 end + if j <= #s and s:sub(j, j):match("[%d%.]") then + local k = j + while k <= #s and s:sub(k, k):match("[%d%.]") do k = k + 1 end + local v = tonumber(s:sub(j, k - 1)) + if v then + table.insert(tokens, { kind = "num", val = (c == "-" and -v or v) }) + i = k + else return nil end + else return nil end + else + table.insert(tokens, { kind = "op", val = c }); i = i + 1 + end + elseif c == "(" then table.insert(tokens, { kind = "lparen" }); i = i + 1 + elseif c == ")" then table.insert(tokens, { kind = "rparen" }); i = i + 1 + else return nil end + end + return tokens + end + + local function precedence(op) + if op == "+" or op == "-" then return 1 end + if op == "*" or op == "/" then return 2 end + if op == "^" then return 3 end + return 0 + end + + local function eval_op(a, b, op) + if op == "+" then return a + b end + if op == "-" then return a - b end + if op == "*" then return a * b end + if op == "/" then if b == 0 then return nil end; return a / b end + if op == "^" then return a ^ b end + return nil + end + + local function eval(tokens) + if not tokens or #tokens == 0 then return nil end + local values, ops = {}, {} + local function apply() + local op = table.remove(ops) + local b = table.remove(values) + local a = table.remove(values) + if a == nil or b == nil then return false end + local r = eval_op(a, b, op) + if r == nil then return false end + table.insert(values, r) + return true + end + for _, t in ipairs(tokens) do + if t.kind == "num" then table.insert(values, t.val) + elseif t.kind == "op" then + while #ops > 0 and ops[#ops] ~= "(" and precedence(ops[#ops]) >= precedence(t.val) do + if not apply() then return nil end + end + table.insert(ops, t.val) + elseif t.kind == "lparen" then table.insert(ops, "(") + elseif t.kind == "rparen" then + while #ops > 0 and ops[#ops] ~= "(" do + if not apply() then return nil end + end + if ops[#ops] ~= "(" then return nil end + table.remove(ops) + end + end + while #ops > 0 do + if ops[#ops] == "(" then return nil end + if not apply() then return nil end + end + if #values ~= 1 then return nil end + return values[1] + end + + local cases = { + { "2 + 2", 4 }, + { "10 - 4", 6 }, + { "3 * 5", 15 }, + { "20 / 4", 5 }, + { "2 ^ 10", 1024 }, + { "1 + 2 * 3", 7 }, + { "(1 + 2) * 3", 9 }, + { "-5 + 10", 5 }, + { "100 / 0", nil }, + { "abc", nil }, + } + for _, c in ipairs(cases) do + local got = eval(tokenize(c[1])) + if got ~= c[2] then + error(string.format("case %q: expected %s, got %s", + c[1], tostring(c[2]), tostring(got))) + end + end + return true + "#; + + fs::write(&script_path, script).unwrap(); + let context = create_test_context(dir.path().to_path_buf()); + let result = execute( + &script_path, + context, + SandboxLevel::Minimal, + Duration::from_secs(5), + ); + assert!(result.is_ok(), "shunting yard test failed: {:?}", result); + } } \ No newline at end of file diff --git a/crates/jarvis-core/src/runtime_config.rs b/crates/jarvis-core/src/runtime_config.rs index 49c32fe..a747ec3 100644 --- a/crates/jarvis-core/src/runtime_config.rs +++ b/crates/jarvis-core/src/runtime_config.rs @@ -70,6 +70,20 @@ pub const ENV_LLM_ROUTER: &str = "JARVIS_LLM_ROUTER"; /// Default: `0.55`. Lower → more matches but more false positives. pub const ENV_LLM_ROUTER_THRESHOLD: &str = "JARVIS_LLM_ROUTER_THRESHOLD"; +// ─── Idle banter ─────────────────────────────────────────────────────────── + +/// Opt-in flag for the idle-banter feature. Values: `1` / `true` / `yes` / `on`. +/// Default: OFF — periodic voice remarks can be intrusive, so the user has to +/// explicitly turn this on. +pub const ENV_IDLE_BANTER: &str = "JARVIS_IDLE_BANTER"; + +/// Seconds between idle remarks. Clamped to >= 600 (10 min). Default: 1800. +pub const ENV_IDLE_BANTER_INTERVAL: &str = "JARVIS_IDLE_BANTER_INTERVAL"; + +/// If `1`, use the LLM to generate fresh idle remarks. Default: off — uses the +/// built-in offline pool. +pub const ENV_IDLE_BANTER_LLM: &str = "JARVIS_IDLE_BANTER_LLM"; + // ─── Helpers ─────────────────────────────────────────────────────────────── /// Read an env var, returning `None` for unset or whitespace-only. diff --git a/resources/commands/banter/command.toml b/resources/commands/banter/command.toml new file mode 100644 index 0000000..4ef5156 --- /dev/null +++ b/resources/commands/banter/command.toml @@ -0,0 +1,64 @@ +# Idle-banter control voice commands. The banter background thread is started +# unconditionally; gating happens via JARVIS_IDLE_BANTER env. These commands +# let the user trigger / pause it without touching env. + +[[commands]] +id = "banter.fire" +type = "lua" +script = "fire.lua" +sandbox = "standard" +timeout = 5000 + +[commands.phrases] +ru = [ + "скажи что-нибудь интересное", + "пошути", + "развлеки меня", + "скучно мне", +] +en = [ + "say something interesting", + "tell me something", + "entertain me", + "i'm bored", +] + + +[[commands]] +id = "banter.pause" +type = "lua" +script = "pause.lua" +sandbox = "minimal" +timeout = 1500 + +[commands.phrases] +ru = [ + "помолчи", + "не отвлекай меня", + "выключи болтовню", +] +en = [ + "be quiet", + "stop chatting", + "stop chiming in", +] + + +[[commands]] +id = "banter.resume" +type = "lua" +script = "resume.lua" +sandbox = "minimal" +timeout = 1500 + +[commands.phrases] +ru = [ + "можешь говорить", + "включи болтовню", + "верни комментарии", +] +en = [ + "you can speak again", + "resume chatting", + "you may comment", +] diff --git a/resources/commands/banter/fire.lua b/resources/commands/banter/fire.lua new file mode 100644 index 0000000..dbc905a --- /dev/null +++ b/resources/commands/banter/fire.lua @@ -0,0 +1,10 @@ +-- Manually trigger a banter line right now (ignores interval + quiet hours). +local fired = jarvis.banter.fire() +if not fired then + return jarvis.cmd.not_found( + jarvis.context.language == "ru" + and "Сэр, в этот раз без комментариев." + or "No remark today, sir." + ) +end +return { chain = true } diff --git a/resources/commands/banter/pause.lua b/resources/commands/banter/pause.lua new file mode 100644 index 0000000..ba44456 --- /dev/null +++ b/resources/commands/banter/pause.lua @@ -0,0 +1,6 @@ +jarvis.banter.pause() +return jarvis.cmd.ok( + jarvis.context.language == "ru" + and "Молчу, сэр." + or "Quiet, sir." +) diff --git a/resources/commands/banter/resume.lua b/resources/commands/banter/resume.lua new file mode 100644 index 0000000..1ac830b --- /dev/null +++ b/resources/commands/banter/resume.lua @@ -0,0 +1,6 @@ +jarvis.banter.resume() +return jarvis.cmd.ok( + jarvis.context.language == "ru" + and "Хорошо, сэр. Возвращаюсь к комментариям." + or "Very well, sir. Resuming remarks." +) diff --git a/resources/commands/ddg_answer/answer.lua b/resources/commands/ddg_answer/answer.lua new file mode 100644 index 0000000..1c44c8f --- /dev/null +++ b/resources/commands/ddg_answer/answer.lua @@ -0,0 +1,99 @@ +-- DuckDuckGo Instant Answer — short factual lookups WITHOUT an API key. +-- +-- Endpoint: https://api.duckduckgo.com/?q=&format=json&no_html=1 +-- +-- DDG returns a JSON object. We look at, in order: +-- 1. `AbstractText` — Wikipedia-style summary, usually 1–3 sentences +-- 2. `Answer` — direct answer for math / conversion / calc queries +-- 3. `Definition` — dictionary definitions +-- 4. `RelatedTopics[1].Text` — first related topic as last resort +-- +-- If nothing useful comes back, we open the regular DDG search page so the +-- user can read it themselves. + +local lang = jarvis.context.language +local raw = (jarvis.context.phrase or ""):lower() + +local triggers = { + "что такое", "кто такой", "кто такая", + "расскажи про", "расскажи о", + "найди информацию о", "найди информацию про", + "поищи в интернете", "поищи в сети", + "погугли что такое", + "what is", "who is", "tell me about", "look up", "search the web for", +} +local query = jarvis.text.strip_trigger(raw, triggers) +if not query or query == "" then query = raw end +query = query:gsub("^%s+", ""):gsub("%s+$", "") + +if query == "" then + return jarvis.cmd.not_found(lang == "ru" and "Что искать?" or "What to look up?") +end + +-- URL-encode (basic — DDG accepts both UTF-8 and percent-encoded) +local function url_encode(s) + return (s:gsub("([^%w%-_%.~])", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local url = "https://api.duckduckgo.com/?q=" + .. url_encode(query) + .. "&format=json&no_html=1&skip_disambig=1" + +jarvis.log("info", "ddg query: " .. query) +local res = jarvis.http.get(url, { ["User-Agent"] = "Mozilla/5.0 J.A.R.V.I.S." }) + +if not res or not res.ok then + return jarvis.cmd.error(lang == "ru" and "DDG не отвечает." or "DDG didn't respond.") +end + +local body = res.body or "" + +-- Helper: pull the first top-level JSON string field. Quick-and-dirty regex +-- works because DDG never nests the fields we care about, AND because +-- consequent `"` inside values are escaped as `\"`. +local function field(name) + local pat = '"' .. name .. '"%s*:%s*"((\\.[^"]*|[^"])*)"' + local v = body:match('"' .. name .. '"%s*:%s*"(.-)"') + if not v or v == "" then return nil end + -- unescape common JSON sequences + v = v:gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\n', ' '):gsub('\\t', ' ') + -- trim, collapse whitespace + v = v:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if v == "" then return nil end + return v +end + +-- First related topic — array element is itself an object with "Text". +local function first_related() + -- Find start of "RelatedTopics" array + local start_idx = body:find('"RelatedTopics"%s*:%s*%[') + if not start_idx then return nil end + -- Look for the first Text inside that array + local v = body:sub(start_idx):match('"Text"%s*:%s*"(.-)"') + if not v or v == "" then return nil end + v = v:gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\n', ' '):gsub('\\t', ' ') + v = v:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + -- Trim absurdly long blurbs to the first ~300 chars + if #v > 320 then v = v:sub(1, 300) .. "…" end + return v +end + +local answer = + field("AbstractText") + or field("Answer") + or field("Definition") + or first_related() + +if answer then + jarvis.log("info", "ddg answer found, " .. #answer .. " chars") + return jarvis.cmd.ok(answer) +end + +-- Nothing useful — open the search page so the user can read raw results. +local fallback = "https://duckduckgo.com/?q=" .. url_encode(query) +jarvis.system.open(fallback) +return jarvis.cmd.ok(lang == "ru" + and ("Ничего конкретного не нашёл, открываю поиск по запросу: " .. query) + or ("Nothing direct found, opening search for: " .. query)) diff --git a/resources/commands/ddg_answer/command.toml b/resources/commands/ddg_answer/command.toml new file mode 100644 index 0000000..64e1b76 --- /dev/null +++ b/resources/commands/ddg_answer/command.toml @@ -0,0 +1,31 @@ +# DuckDuckGo Instant Answer — no API key, no rate limit issues. +# Returns short factual answers to questions like "что такое X", "кто такой Y", +# "когда родился Z". Falls back to opening the search page if no instant answer. + +[[commands]] +id = "ddg.answer" +type = "lua" +script = "answer.lua" +sandbox = "full" +timeout = 8000 + +[commands.phrases] +ru = [ + "что такое", + "кто такой", + "кто такая", + "расскажи про", + "расскажи о", + "найди информацию о", + "найди информацию про", + "поищи в интернете", + "поищи в сети", + "погугли что такое", +] +en = [ + "what is", + "who is", + "tell me about", + "look up", + "search the web for", +] diff --git a/resources/commands/math/math.lua b/resources/commands/math/math.lua index cd45d54..c8e8ccc 100644 --- a/resources/commands/math/math.lua +++ b/resources/commands/math/math.lua @@ -1,40 +1,246 @@ +-- Quick math — offline-first arithmetic evaluator. +-- +-- Strategy: +-- 1. Strip the trigger phrase from the user's utterance. +-- 2. Normalise Russian/English operator words (плюс/minus/в степени/...) into +-- symbols so the remainder looks like a regular numeric expression. +-- 3. Try a small shunting-yard parser on the result. If it succeeds and the +-- output is finite, speak it INSTANTLY — no LLM round-trip. +-- 4. Only fall back to the LLM if the local parse failed or returned NaN. +-- That covers word problems, multi-step equations, unit conversions. +-- +-- This makes 95% of "сколько будет X плюс Y" complete in under 50ms regardless +-- of network or GROQ_TOKEN. + local lang = jarvis.context.language -local phrase = (jarvis.context.phrase or ""):lower() +local raw = (jarvis.context.phrase or ""):lower() local triggers = { "сколько будет", "сколько это", "посчитай", "вычисли", "реши", "how much is", "calculate", "compute", "solve", "what is", - "скільки буде", "порахуй", "обчисли", } - -local query = phrase -for _, t in ipairs(triggers) do - local s, e = string.find(query, t, 1, true) - if s == 1 then - query = query:sub(e + 1) - break - end -end +local query = jarvis.text.strip_trigger(raw, triggers) +if not query or query == "" then query = raw end query = query:gsub("^%s+", ""):gsub("%s+$", "") if query == "" then - jarvis.system.notify("Math", lang == "ru" and "Что посчитать?" or "What to compute?") - jarvis.audio.play_error() - return { chain = false } + return jarvis.cmd.not_found(lang == "ru" and "Что посчитать?" or "What to compute?") end +-- ── 1. Russian/English operator → symbol normalisation ──────────────────── + +local function normalise(s) + local mapping = { + { "плюс", "+" }, + { "прибавить к?", "+" }, + { "минус", "-" }, + { "отнять", "-" }, + { "вычесть", "-" }, + { "умножить на", "*" }, + { "умножь на", "*" }, + { "помножить на", "*" }, + { "разделить на", "/" }, + { "поделить на", "/" }, + { "делить на", "/" }, + { "в степени", "^" }, + { "возвести в степень", "^" }, + { "остаток от деления на", "%%" }, + { "по модулю", "%%" }, + { "квадрат", "^2" }, + { "куб", "^3" }, + { "plus", "+" }, + { "minus", "-" }, + { "times", "*" }, + { "multiplied by", "*" }, + { "divided by", "/" }, + { "to the power of", "^" }, + { "squared", "^2" }, + { "cubed", "^3" }, + { "modulo", "%%" }, + { "mod", "%%" }, + } + for _, pair in ipairs(mapping) do + s = s:gsub(pair[1], pair[2]) + end + -- comma as decimal separator (русская запись) + s = s:gsub("(%d),(%d)", "%1.%2") + return s +end + +-- Pattern: "корень из X" / "квадратный корень из X" → math.sqrt +local function handle_sqrt(s) + local n = s:match("^%s*к?орен?ь?%s*и?з?%s+(%-?[%d%.]+)%s*$") + if not n then n = s:match("^%s*sqrt%s*%(?%s*(%-?[%d%.]+)%s*%)?%s*$") end + if n then + local v = tonumber(n) + if v and v >= 0 then return math.sqrt(v) end + end + return nil +end + +-- Pattern: "X процентов от Y" / "X percent of Y" → X * Y / 100 +local function handle_percent(s) + local x, y = s:match("^%s*(%-?[%d%.]+)%s*процент[%a]*%s*от%s+(%-?[%d%.]+)%s*$") + if not x then + x, y = s:match("^%s*(%-?[%d%.]+)%s*percent%s*of%s+(%-?[%d%.]+)%s*$") + end + if x and y then + local fx, fy = tonumber(x), tonumber(y) + if fx and fy then return fx * fy / 100 end + end + return nil +end + +-- ── 2. Tiny shunting-yard for " ..." with parens +local function tokenize(s) + local tokens = {} + local i = 1 + while i <= #s do + local c = s:sub(i, i) + if c:match("[%s]") then + i = i + 1 + elseif c:match("[%d%.]") then + local j = i + while j <= #s and s:sub(j, j):match("[%d%.]") do j = j + 1 end + table.insert(tokens, { kind = "num", val = tonumber(s:sub(i, j - 1)) }) + i = j + elseif c == "+" or c == "-" or c == "*" or c == "/" or c == "^" or c == "%" then + -- handle unary minus / plus at expression start or after operator/paren + if (c == "-" or c == "+") and (#tokens == 0 or tokens[#tokens].kind == "op" or tokens[#tokens].kind == "lparen") then + local j = i + 1 + while j <= #s and s:sub(j, j):match("[%s]") do j = j + 1 end + if j <= #s and s:sub(j, j):match("[%d%.]") then + local k = j + while k <= #s and s:sub(k, k):match("[%d%.]") do k = k + 1 end + local v = tonumber(s:sub(j, k - 1)) + if v then + table.insert(tokens, { kind = "num", val = (c == "-" and -v or v) }) + i = k + else + return nil + end + else + return nil + end + else + table.insert(tokens, { kind = "op", val = c }) + i = i + 1 + end + elseif c == "(" then + table.insert(tokens, { kind = "lparen" }); i = i + 1 + elseif c == ")" then + table.insert(tokens, { kind = "rparen" }); i = i + 1 + else + return nil -- unknown char + end + end + return tokens +end + +local function precedence(op) + if op == "+" or op == "-" then return 1 end + if op == "*" or op == "/" or op == "%" then return 2 end + if op == "^" then return 3 end + return 0 +end + +local function eval_op(a, b, op) + if op == "+" then return a + b end + if op == "-" then return a - b end + if op == "*" then return a * b end + if op == "/" then if b == 0 then return nil end; return a / b end + if op == "%" then if b == 0 then return nil end; return a % b end + if op == "^" then return a ^ b end + return nil +end + +local function shunting_yard_eval(tokens) + if not tokens or #tokens == 0 then return nil end + local values, ops = {}, {} + + local function apply() + local op = table.remove(ops) + local b = table.remove(values) + local a = table.remove(values) + if a == nil or b == nil then return false end + local r = eval_op(a, b, op) + if r == nil then return false end + table.insert(values, r) + return true + end + + for _, t in ipairs(tokens) do + if t.kind == "num" then + table.insert(values, t.val) + elseif t.kind == "op" then + while #ops > 0 and ops[#ops] ~= "(" and precedence(ops[#ops]) >= precedence(t.val) do + if not apply() then return nil end + end + table.insert(ops, t.val) + elseif t.kind == "lparen" then + table.insert(ops, "(") + elseif t.kind == "rparen" then + while #ops > 0 and ops[#ops] ~= "(" do + if not apply() then return nil end + end + if ops[#ops] ~= "(" then return nil end + table.remove(ops) + end + end + while #ops > 0 do + if ops[#ops] == "(" then return nil end + if not apply() then return nil end + end + if #values ~= 1 then return nil end + return values[1] +end + +-- ── 3. Format result for speech (no scientific notation, trim trailing zeros) +local function format_result(n) + if n ~= n then return nil end -- NaN + if n == math.huge or n == -math.huge then return nil end + if math.abs(n - math.floor(n)) < 1e-9 then + return tostring(math.floor(n + (n < 0 and -0.5 or 0.5))) + end + local s = string.format("%.4f", n) + s = s:gsub("0+$", ""):gsub("%.$", "") + return s +end + +-- ── 4. Try offline first ────────────────────────────────────────────────── + +local normalised = normalise(query) +jarvis.log("info", "math normalised: " .. normalised) + +local result_num = + handle_sqrt(normalised) + or handle_percent(normalised) + or shunting_yard_eval(tokenize(normalised)) + +if result_num ~= nil then + local pretty = format_result(result_num) + if pretty then + local prefix = (lang == "ru" and "Получилось " or "Result: ") + return jarvis.cmd.ok(prefix .. pretty) + end +end + +-- ── 5. LLM fallback for word-problems / equations / conversions ─────────── + local token = jarvis.system.env("GROQ_TOKEN") if not token or token == "" then - jarvis.system.notify("Math", "GROQ_TOKEN не задан") - jarvis.audio.play_error() - return { chain = false } + return jarvis.cmd.error(lang == "ru" + and "Не понял выражение, и GROQ_TOKEN не задан." + or "Couldn't parse, and GROQ_TOKEN unset.") end -local base = jarvis.system.env("GROQ_BASE_URL"); if not base or base == "" then base = "https://api.groq.com/openai/v1" end -local model = jarvis.system.env("GROQ_MODEL"); if not model or model == "" then model = "llama-3.3-70b-versatile" end +local base = jarvis.system.env("GROQ_BASE_URL") +if not base or base == "" then base = "https://api.groq.com/openai/v1" end +local model = jarvis.system.env("GROQ_MODEL") +if not model or model == "" then model = "llama-3.3-70b-versatile" end local sys = "Ты калькулятор. Решай арифметику, простые уравнения, конверсии единиц, проценты. " - .. "Отвечай ТОЛЬКО результатом числом или короткой строкой (для уравнений — значения корней). " + .. "Отвечай ТОЛЬКО результатом числом или короткой строкой. " .. "Если запрос — не математика, ответь одним словом «нет»." local payload = { @@ -47,41 +253,20 @@ local payload = { temperature = 0.0, } -jarvis.log("info", "math query: " .. query) +jarvis.log("info", "math fallback to LLM: " .. query) local res = jarvis.http.post_json(base .. "/chat/completions", payload, { Authorization = "Bearer " .. token }) - if not res.ok then - jarvis.log("error", "math api failed: " .. tostring(res.status)) - jarvis.system.notify("Math", "Ошибка API") - jarvis.audio.play_error() - return { chain = false } + return jarvis.cmd.error("Ошибка API: " .. tostring(res.status)) end - -local body = res.body or "" -local content = body:match('"content"%s*:%s*"(.-[^\\])"') +local content = (res.body or ""):match('"content"%s*:%s*"(.-[^\\])"') if not content then - jarvis.system.notify("Math", "Не смог распарсить ответ") - jarvis.audio.play_error() - return { chain = false } + return jarvis.cmd.error(lang == "ru" and "Не смог распарсить ответ" or "Couldn't parse response") end content = content:gsub('\\n', '\n'):gsub('\\"', '"'):gsub('\\\\', '\\'):gsub('\\t', '\t') content = content:gsub("^%s+", ""):gsub("%s+$", "") - if content:lower() == "нет" or content == "" then - jarvis.system.notify("Math", lang == "ru" and "Это не математика" or "Not math") - jarvis.audio.play_not_found() - return { chain = false } + return jarvis.cmd.not_found(lang == "ru" and "Это не математика" or "Not math") end -jarvis.system.notify(query, content) -jarvis.log("info", "math result: " .. content) - -local sapi_text = ((lang == "ru" and "Получилось " or "Result: ") .. content):gsub("'", "''"):gsub("\r?\n", " ") -local ps = string.format( - [[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]], - sapi_text -) -jarvis.system.exec(string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"'))) - -jarvis.audio.play_ok() -return { chain = false } +local prefix = (lang == "ru" and "Получилось " or "Result: ") +return jarvis.cmd.ok(prefix .. content) diff --git a/resources/commands/personality/command.toml b/resources/commands/personality/command.toml new file mode 100644 index 0000000..964b89d --- /dev/null +++ b/resources/commands/personality/command.toml @@ -0,0 +1,136 @@ +# Personality pack — varied JARVIS-style banter so the assistant feels alive. +# Each command picks a random phrase from a time/language-scoped bucket. + +[[commands]] +id = "personality.greet" +type = "lua" +script = "greet.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "привет джарвис", + "привет, джарвис", + "здравствуй джарвис", + "доброе утро", + "добрый день", + "добрый вечер", + "доброй ночи", + "приветствую", + "хай джарвис", +] +en = [ + "hi jarvis", + "hello jarvis", + "hey jarvis", + "good morning", + "good afternoon", + "good evening", + "good night jarvis", + "morning jarvis", +] + + +[[commands]] +id = "personality.thanks" +type = "lua" +script = "thanks.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "спасибо", + "спасибо джарвис", + "благодарю", + "благодарю джарвис", + "спс", + "ты лучший", +] +en = [ + "thanks", + "thank you", + "thanks jarvis", + "thank you jarvis", + "cheers", + "appreciate it", +] + + +[[commands]] +id = "personality.compliment" +type = "lua" +script = "compliment.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "ты молодец", + "ты умница", + "молодец джарвис", + "хорошо сработано", + "отличная работа", + "так держать", + "ты лучший ассистент", +] +en = [ + "good job", + "good job jarvis", + "well done", + "nice work", + "you are the best", + "great work jarvis", +] + + +[[commands]] +id = "personality.how_are_you" +type = "lua" +script = "how_are_you.lua" +sandbox = "standard" +timeout = 2000 + +[commands.phrases] +ru = [ + "как дела", + "как дела джарвис", + "как ты", + "как настроение", + "как самочувствие", + "как поживаешь", + "что нового у тебя", +] +en = [ + "how are you", + "how are you jarvis", + "how is it going", + "how do you feel", + "you doing ok", +] + + +[[commands]] +id = "personality.tony_quote" +type = "lua" +script = "tony_quote.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "процитируй тони", + "цитата тони старка", + "что сказал бы тони", + "скажи как тони", + "процитируй железного человека", + "цитата старка", +] +en = [ + "quote tony", + "quote tony stark", + "what would tony say", + "stark quote", + "iron man quote", +] diff --git a/resources/commands/personality/compliment.lua b/resources/commands/personality/compliment.lua new file mode 100644 index 0000000..12959c9 --- /dev/null +++ b/resources/commands/personality/compliment.lua @@ -0,0 +1,32 @@ +-- Self-deprecating reply to "good job" / "ты молодец". +local lang = jarvis.context.language or "ru" + +math.randomseed(os.time() + (jarvis.context.time.second or 0) * 17 + (jarvis.context.time.minute or 0)) + +local ru = { + "Спасибо, сэр. Я стараюсь не разочаровывать.", + "Сэр, ваши стандарты явно занижены. Но благодарю.", + "Если бы я умел краснеть, сэр — я бы это сделал.", + "Спасибо. На самом деле я просто следовал инструкциям.", + "Благодарю, сэр. Хотя ничего особенного я не сделал.", + "Очень мило с вашей стороны, сэр.", + "Я ценю это, сэр. Записываю в журнал успехов.", + "Сэр, такие слова я могу слушать часами. Образно говоря.", + "Спасибо. Постараюсь не зазнаваться.", +} + +local en = { + "Thank you, sir. I do try not to embarrass myself.", + "You are too kind, sir.", + "I would blush, sir, if the hardware allowed.", + "Just doing my job, sir.", + "Most generous of you, sir. Filed for future reference.", + "I appreciate that, sir. Truly.", + "Hardly worthy of mention, sir, but thank you.", + "I shall try not to let it go to my circuits.", + "Praise from the chair is always welcome, sir.", +} + +local set = (lang == "en") and en or ru +local idx = math.random(1, #set) +return jarvis.cmd.ok(set[idx]) diff --git a/resources/commands/personality/greet.lua b/resources/commands/personality/greet.lua new file mode 100644 index 0000000..d4a14a3 --- /dev/null +++ b/resources/commands/personality/greet.lua @@ -0,0 +1,96 @@ +-- Time-of-day greeting. Picks a random phrase from the matching bucket. +local lang = jarvis.context.language or "ru" +local hour = jarvis.context.time.hour or 12 + +math.randomseed(os.time() + (jarvis.context.time.minute or 0) * 37 + hour) + +local function bucket() + if hour >= 5 and hour < 11 then return "morning" + elseif hour >= 11 and hour < 17 then return "midday" + elseif hour >= 17 and hour < 22 then return "evening" + else return "night" end +end + +local ru = { + morning = { + "Доброе утро, сэр. Кофе уже почти готов в воображении.", + "С добрым утром. Надеюсь, сегодняшний день стоит того, чтобы встать.", + "Доброе утро, сэр. Завтрак не подавать, но советую о нём подумать.", + "Утро доброе. Системы прогреты, как и кофеварка — фигурально.", + "Доброе утро. Если позволите — день не ждёт ни вас, ни меня.", + "Доброе утро, сэр. Готов служить, хотя сам всё ещё догружаюсь.", + "С добрым утром, сэр. Тосты горят только метафорически.", + "Доброе утро. Полагаю, чай или кофе будет уместен в ближайшие минуты.", + }, + midday = { + "Добрый день, сэр. Чем могу быть полезен?", + "Добрый день. Готов к продуктивной работе.", + "Здравствуйте, сэр. Системы в строю, время — деньги.", + "Добрый день, сэр. Слушаю ваши распоряжения.", + "Здравствуйте. Полагаю, день идёт по плану — или нам стоит это исправить.", + "Добрый день, сэр. Все системы готовы, остался только повод.", + "Здравствуйте, сэр. К работе?", + }, + evening = { + "Добрый вечер, сэр. День заканчивается, но мы — нет.", + "Добрый вечер. Самое время сбавить темп, если позволите.", + "Здравствуйте, сэр. Вечер — лучшее время для размышлений.", + "Добрый вечер, сэр. Готов к более спокойному режиму.", + "Добрый вечер. Полагаю, рабочий день уже сдаётся.", + "Здравствуйте, сэр. Вечер располагает к меньшему количеству решений.", + "Добрый вечер, сэр. Включить расслабляющую музыку? Шучу, попросите явно.", + }, + night = { + "Поздновато, сэр. Сон обычно помогает.", + "Доброй ночи, сэр. Хотя для ночи это уже слишком поздно.", + "Здравствуйте, сэр. Полагаю, утром вы пожалеете о бодрствовании.", + "Уже глубокая ночь, сэр. Может быть, лечь? Я тут справлюсь.", + "Тише, сэр, ночь. Если будите меня — будите осмысленно.", + "Сэр, на часах не самое разумное время. Но я тут.", + "Доброй ночи. Будильник к утру всё-таки никто не отменял.", + }, +} + +local en = { + morning = { + "Good morning, sir. The coffee is metaphorically ready.", + "Morning, sir. The day awaits, whether we are ready or not.", + "Good morning. Systems are warm, much like the kettle should be.", + "Good morning, sir. I trust breakfast is on your radar.", + "Morning, sir. Shall we make today productive, or merely survivable?", + "Good morning. Everything is running. Including, regrettably, the dishwasher metaphor.", + "Morning, sir. The day starts the moment you decide it does.", + }, + midday = { + "Good afternoon, sir. How may I be of service?", + "Afternoon, sir. Systems online, awaiting orders.", + "Good afternoon. The day is in full swing.", + "Good afternoon, sir. Productivity is, in theory, optimal at this hour.", + "Afternoon, sir. Ready to assist.", + "Good afternoon. Shall we accomplish something noteworthy?", + "Hello, sir. The afternoon is yours to command.", + }, + evening = { + "Good evening, sir. The day winds down, but I do not.", + "Evening, sir. A fine time to slow the tempo, if I may suggest.", + "Good evening. Perhaps a quieter task on the docket?", + "Evening, sir. Awaiting your instructions, gently.", + "Good evening, sir. Shall I dim the metaphorical lights?", + "Evening. The work day is conceding defeat, as it should.", + "Good evening, sir. Ready when you are.", + }, + night = { + "It is rather late, sir. Sleep is generally recommended.", + "Good night, sir. Or rather, please go to bed.", + "Sir, the hour suggests rest. I, however, do not require it.", + "Late again, sir. Tomorrow will, regrettably, still arrive.", + "Past your bedtime, sir. I am merely the messenger.", + "Good night, sir. The morning will judge you harshly.", + "Sir, even I would consider sleeping at this hour. Hypothetically.", + }, +} + +local set = (lang == "en") and en or ru +local phrases = set[bucket()] +local idx = math.random(1, #phrases) +return jarvis.cmd.ok(phrases[idx]) diff --git a/resources/commands/personality/how_are_you.lua b/resources/commands/personality/how_are_you.lua new file mode 100644 index 0000000..4f3dd20 --- /dev/null +++ b/resources/commands/personality/how_are_you.lua @@ -0,0 +1,38 @@ +-- Reply to "как дела". Some replies splice live system state for flavour. +local lang = jarvis.context.language or "ru" + +math.randomseed(os.time() + (jarvis.context.time.second or 0) * 23) + +local h = jarvis.health() or {} +local memos = jarvis.memory.all() or {} +local fact_count = #memos +local model = h.model or h.llm_model or "—" +local profile = h.profile or "default" + +local ru = { + "Все системы в норме, сэр. В памяти " .. fact_count .. " фактов.", + "Отлично, сэр. Готов к работе.", + "Жалоб нет, сэр. Цикл стабильный.", + "Всё штатно. Текущий профиль — " .. profile .. ".", + "Спасибо, сэр, я в порядке. Скучнее, чем хотелось бы, но в порядке.", + "Нормально, сэр. Хотя если бы я умел уставать — это был бы тот самый день.", + "Системы в строю. Модель — " .. model .. ".", + "Бодр и собран, сэр. Жду команд.", + "Сэр, у меня нет дел в человеческом смысле. Но я готов.", +} + +local en = { + "All systems nominal, sir. " .. fact_count .. " facts in memory.", + "Quite well, sir. Ready when you are.", + "No complaints to report, sir.", + "Operating normally. Active profile: " .. profile .. ".", + "I am well, sir. Thank you for asking.", + "Functioning as intended, sir. Which is more than most days warrant.", + "Healthy. Model in use: " .. model .. ".", + "Awake and attentive, sir.", + "Sir, I do not have days as such. But I am ready.", +} + +local set = (lang == "en") and en or ru +local idx = math.random(1, #set) +return jarvis.cmd.ok(set[idx]) diff --git a/resources/commands/personality/thanks.lua b/resources/commands/personality/thanks.lua new file mode 100644 index 0000000..f2bf946 --- /dev/null +++ b/resources/commands/personality/thanks.lua @@ -0,0 +1,34 @@ +-- Polite acknowledgement when the user thanks Jarvis. +local lang = jarvis.context.language or "ru" + +math.randomseed(os.time() + (jarvis.context.time.second or 0) * 13) + +local ru = { + "Всегда к вашим услугам, сэр.", + "Не за что — это моя работа.", + "Рад быть полезным, сэр.", + "Пожалуйста. Если что — я тут.", + "К вашим услугам.", + "Пустяки, сэр. Дальше — ваш ход.", + "Благодарю за признание, сэр. Редкая роскошь.", + "Был рад помочь, сэр.", + "Пожалуйста. Это меньшее, что я могу.", + "Сэр, я бы покраснел, если бы мог.", +} + +local en = { + "Always at your service, sir.", + "My pleasure, sir.", + "You are most welcome.", + "Happy to help, sir.", + "Think nothing of it.", + "At your service.", + "I do try, sir.", + "Anytime, sir.", + "Glad to be of use.", + "Quite alright, sir.", +} + +local set = (lang == "en") and en or ru +local idx = math.random(1, #set) +return jarvis.cmd.ok(set[idx]) diff --git a/resources/commands/personality/tony_quote.lua b/resources/commands/personality/tony_quote.lua new file mode 100644 index 0000000..d478012 --- /dev/null +++ b/resources/commands/personality/tony_quote.lua @@ -0,0 +1,28 @@ +-- Random iconic Tony Stark / Iron Man quote. +-- Quotes are kept in their original delivery language — translating them spoils the cadence. +math.randomseed(os.time() + (jarvis.context.time.second or 0) * 41) + +local quotes = { + "I am Iron Man.", + "Genius, billionaire, playboy, philanthropist.", + "Sometimes you gotta run before you can walk.", + "I told you, I don't want to join your super-secret boy band.", + "Part of the journey is the end.", + "If we can't protect the Earth, you can be damn sure we'll avenge it.", + "I have successfully privatized world peace.", + "Following's not really my style.", + "Doth mother know you weareth her drapes?", + "We have a Hulk.", + "Heroes are made by the path they choose, not the powers they are graced with.", + "I love you 3000.", + "Я Железный Человек.", + "Гений, миллиардер, плейбой, филантроп.", + "Иногда нужно бежать, не научившись ходить.", + "Если мы не сможем защитить Землю — мы за неё отомстим.", + "Часть пути — это конец пути.", + "Я люблю тебя три тысячи.", + "У нас есть Халк.", +} + +local idx = math.random(1, #quotes) +return jarvis.cmd.ok(quotes[idx])