J.A.R.V.I.S-rust/crates/jarvis-core/src/lua/engine.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

180 lines
No EOL
6 KiB
Rust

use mlua::{Lua, Value, StdLib};
use std::path::PathBuf;
use std::time::Duration;
use std::fs;
use super::sandbox::SandboxLevel;
use super::error::LuaError;
use super::{CommandContext, CommandResult};
use super::api;
pub struct LuaEngine {
lua: Lua,
sandbox: SandboxLevel,
}
impl LuaEngine {
pub fn new(sandbox: SandboxLevel) -> Result<Self, LuaError> {
// select which standard libraries to load based on sandbox access level
let std_libs = match sandbox {
SandboxLevel::Minimal => {
StdLib::TABLE | StdLib::STRING | StdLib::MATH
}
SandboxLevel::Standard => {
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8
}
SandboxLevel::Full => {
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8 | StdLib::OS
}
};
let lua = Lua::new_with(std_libs, mlua::LuaOptions::default())
.map_err(|e| LuaError::InitError(e.to_string()))?;
// remove dangerous globals regardless of sandbox
{
let globals = lua.globals();
// always remove these
let _ = globals.set("loadfile", Value::Nil);
let _ = globals.set("dofile", Value::Nil);
let _ = globals.set("load", Value::Nil);
let _ = globals.set("loadstring", Value::Nil);
// remove io unless full sandbox
if !matches!(sandbox, SandboxLevel::Full) {
let _ = globals.set("io", Value::Nil);
}
// remove os.execute, os.exit, os.setlocale even in full mode
// for SECURITY REASONS!!!
if matches!(sandbox, SandboxLevel::Full) {
if let Ok(os) = globals.get::<mlua::Table>("os") {
let _ = os.set("execute", Value::Nil);
let _ = os.set("exit", Value::Nil);
let _ = os.set("remove", Value::Nil);
let _ = os.set("rename", Value::Nil);
let _ = os.set("setlocale", Value::Nil);
}
}
}
Ok(Self { lua, sandbox })
}
// Register all jarvis APIs
fn register_api(&self, context: &CommandContext) -> Result<(), LuaError> {
let globals = self.lua.globals();
// main jarvis table
let jarvis = self.lua.create_table()
.map_err(|e| LuaError::InitError(e.to_string()))?;
// always register core APIs
api::core::register(&self.lua, &jarvis)?;
api::audio::register(&self.lua, &jarvis)?;
api::context::register(&self.lua, &jarvis, context)?;
api::tts::register(&self.lua, &jarvis)?;
api::text::register(&self.lua, &jarvis)?;
api::memory::register(&self.lua, &jarvis)?;
api::profile::register(&self.lua, &jarvis)?;
api::scheduler::register(&self.lua, &jarvis)?;
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() {
api::http::register(&self.lua, &jarvis)?;
api::llm::register(&self.lua, &jarvis)?;
api::vision::register(&self.lua, &jarvis)?;
}
if self.sandbox.allows_state() {
api::state::register(&self.lua, &jarvis, &context.command_path)?;
}
if self.sandbox.allows_fs() {
api::fs::register(&self.lua, &jarvis, &context.command_path, self.sandbox)?;
}
api::system::register(&self.lua, &jarvis, self.sandbox)?;
globals.set("jarvis", jarvis)
.map_err(|e| LuaError::InitError(e.to_string()))?;
Ok(())
}
// Main LUA executor
pub fn execute(
&self,
script_path: &PathBuf,
context: CommandContext,
timeout: Duration,
) -> Result<CommandResult, LuaError> {
// register APIs
self.register_api(&context)?;
// load script
let script_content = fs::read_to_string(script_path)
.map_err(|e| LuaError::LoadError(format!("{}: {}", script_path.display(), e)))?;
let script_name = script_path.file_name()
.unwrap()
.to_string_lossy()
.to_string();
// set up timeout hook
let start = std::time::Instant::now();
self.lua.set_hook(mlua::HookTriggers {
every_nth_instruction: Some(1000),
..Default::default()
}, move |_lua, _debug| {
if start.elapsed() > timeout {
Err(mlua::Error::runtime("Script timeout"))
} else {
Ok(mlua::VmState::Continue)
}
}).map_err(|e| LuaError::InitError(e.to_string()))?;
// execute script
let result = self.lua.load(&script_content)
.set_name(&script_name)
.eval::<Value>();
// remove hook
let _ = self.lua.remove_hook();
// result
match result {
Ok(value) => Ok(self.parse_result(value)),
Err(e) => {
if e.to_string().contains("timeout") {
Err(LuaError::Timeout)
} else {
Err(LuaError::RuntimeError(e.to_string()))
}
}
}
}
// Parse Lua return value into CommandResult
fn parse_result(&self, value: Value) -> CommandResult {
match value {
// return { chain = false }
Value::Table(t) => {
let chain = t.get::<bool>("chain").unwrap_or(true);
CommandResult { chain }
}
// return false (shorthand for no chain)
Value::Boolean(chain) => CommandResult { chain },
// return nil or no return = chain
_ => CommandResult::default(),
}
}
}