feat: real-Jarvis Wave 4 — personality, idle banter, persistent history, offline math, DDG search
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

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:
Bossiara13 2026-05-16 13:52:49 +03:00
parent 3f8300dc6f
commit 919d565879
23 changed files with 1450 additions and 54 deletions

View 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: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());
}
}

View file

@ -66,6 +66,8 @@ pub mod plugins;
pub mod wake_trainer;
pub mod idle_banter;
#[cfg(feature = "llm")]
pub mod llm;

View file

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

View file

@ -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>) {

View file

@ -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;

View 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(())
}

View file

@ -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() {

View file

@ -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);
}
}

View file

@ -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.