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.
This commit is contained in:
parent
3f8300dc6f
commit
919d565879
23 changed files with 1450 additions and 54 deletions
|
|
@ -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();
|
||||
|
|
|
|||
304
crates/jarvis-core/src/idle_banter.rs
Normal file
304
crates/jarvis-core/src/idle_banter.rs
Normal file
|
|
@ -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<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());
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,8 @@ pub mod plugins;
|
|||
|
||||
pub mod wake_trainer;
|
||||
|
||||
pub mod idle_banter;
|
||||
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod llm;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
use super::client::ChatMessage;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct ConversationHistory {
|
||||
system: Option<ChatMessage>,
|
||||
turns: Vec<ChatMessage>,
|
||||
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<PathBuf>,
|
||||
}
|
||||
|
||||
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<P: Into<PathBuf>>(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::<Vec<ChatMessage>>(&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<String>) {
|
||||
self.turns.push(ChatMessage::user(content));
|
||||
self.truncate();
|
||||
self.persist();
|
||||
}
|
||||
|
||||
pub fn push_assistant(&mut self, content: impl Into<String>) {
|
||||
self.turns.push(ChatMessage::assistant(content));
|
||||
self.truncate();
|
||||
self.persist();
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<ChatMessage> {
|
||||
|
|
@ -48,11 +80,14 @@ impl ConversationHistory {
|
|||
|
||||
pub fn clear(&mut self) {
|
||||
self.turns.clear();
|
||||
self.persist();
|
||||
}
|
||||
|
||||
pub fn pop_last_user(&mut self) -> Option<ChatMessage> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,19 @@ use std::sync::Arc;
|
|||
|
||||
static HISTORY: Lazy<RwLock<Option<ConversationHistory>>> = Lazy::new(|| RwLock::new(None));
|
||||
|
||||
const HISTORY_FILE: &str = "llm_history.json";
|
||||
|
||||
pub fn init_history(system_prompt: impl Into<String>, 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<String>) {
|
||||
|
|
|
|||
|
|
@ -14,4 +14,5 @@ pub mod vision;
|
|||
pub mod scheduler;
|
||||
pub mod cmd;
|
||||
pub mod health;
|
||||
pub mod macros;
|
||||
pub mod macros;
|
||||
pub mod banter;
|
||||
29
crates/jarvis-core/src/lua/api/banter.rs
Normal file
29
crates/jarvis-core/src/lua/api/banter.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue