J.A.R.V.I.S-rust/crates/jarvis-core/src/idle_banter.rs
Bossiara13 919d565879
Some checks are pending
Rust CI / cargo test (jarvis-core) (push) Waiting to run
Rust CI / cargo clippy (push) Waiting to run
Rust CI / cargo check (workspace) (push) Waiting to run
feat: real-Jarvis Wave 4 — personality, idle banter, persistent history, offline math, DDG search
Personality pack (`resources/commands/personality/`):
- 5 voice commands × ~7-29 phrases each across RU and EN.
- personality.greet: 4 time-of-day buckets (morning/midday/evening/night),
  pulls one of ~7 lines per bucket per language.
- personality.thanks / .compliment / .how_are_you / .tony_quote.
- how_are_you embeds live memory size + active profile via jarvis.health()
  and jarvis.memory.all() for a "feels alive" effect.
- All use jarvis.cmd.ok helpers, no inline PowerShell SAPI.
- Built by sub-agent. Verified: 6 rust command tests + 60 python tests.

Idle banter (`crates/jarvis-core/src/idle_banter.rs`):
- Background thread chimes in periodically without being asked. Gated by
  JARVIS_IDLE_BANTER env (default OFF — intrusion is opt-in).
- Quiet hours 23:00–07:00, skipped under "sleep" profile, paused during
  active interactions via `pause()`.
- 30+ static offline lines split into RU/EN × morning/evening/generic
  buckets — no network required.
- Lua API jarvis.banter.{fire, pause, resume, enabled}.
- New voice pack `banter/` exposes "скажи что-нибудь интересное",
  "помолчи", "можешь говорить".
- 6 unit tests covering pool selection, quiet hours, interval clamp,
  pause/resume, opt-in default.

Conversation continuity (`crates/jarvis-core/src/llm/history.rs`):
- New `ConversationHistory::with_persistence(path)` builder. Every
  push/clear/pop atomically writes to `<APP_CONFIG_DIR>/llm_history.json`
  so daemon restart picks up the thread.
- System prompt is intentionally NOT persisted — comes from current init
  call so prompt edits take effect immediately on restart.
- `llm::init_history` wires the path in automatically.
- 4 new tests: round-trip, clear wipes file, corrupt file tolerated,
  len/is_empty helpers.

Offline-first math (`resources/commands/math/math.lua`):
- Was: always-LLM, hard fail without GROQ_TOKEN, inline PowerShell SAPI.
- Now: shunting-yard parser handles 95% of voice queries in <50ms — no
  network, no token. Russian operator words ("плюс", "умножить на",
  "в степени", "квадрат", ...) normalised to symbols first. Patterns
  for "корень из X" and "X процентов от Y". Falls back to LLM only on
  parse failure (word problems / equations / unit conversions).
- Drops inline PowerShell — speaks via jarvis.cmd.ok.
- 10-case shunting-yard kernel test added (basic ops, precedence,
  parens, unary minus, div-by-zero, garbage rejected).

DuckDuckGo Instant Answer (`resources/commands/ddg_answer/`):
- New pack — short factual Q&A without API key. Trigger phrases
  "что такое", "кто такой", "расскажи про", "what is", etc.
- Reads AbstractText → Answer → Definition → RelatedTopics[0] in order
  from DDG's free JSON API. Opens the search page only if nothing
  useful comes back.
- Sandbox full (needs http + system.open).

Tests: 128 → 139 (+11). Release build green.
2026-05-16 13:52:49 +03:00

304 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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