305 lines
11 KiB
Rust
305 lines
11 KiB
Rust
|
|
//! 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<Mutex<Instant>> = 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::<u64>().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());
|
|||
|
|
}
|
|||
|
|
}
|