//! 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()); } }