feat(scheduler): IMBA-5 proactive scheduler — reminders, daily briefings, intervals

Background thread that wakes J.A.R.V.I.S. on a schedule to speak reminders.
Nothing else competes here on desktop — Алиса/Сири are reactive-only.

Core (crates/jarvis-core/src/scheduler.rs)
  - Schedule::{Daily{h,m}, Interval{secs}, Once{at}} with Schedule::parse for
    "daily HH:MM" / "at HH:MM" / "every N minutes|hours" / "in N minutes|hours".
    Russian units (час/часа/часов/минут/секунд) also accepted.
  - ScheduledTask {id, name, schedule, action, last_fired, enabled, created_at}.
    Action::Speak{text} | Action::Lua{script_path}.
  - JSON persistence at <APP_CONFIG_DIR>/schedule.json, atomic write-through.
  - add/remove/clear/list/find; mark_fired auto-deletes Once tasks.
  - start_background() spawns a 30-second tick thread (idempotent). Each tick
    calls due_tasks(), fires Action via tts::speak_default (after voices::play_reply
    "ahem" cue) or Lua engine.
  - 7 unit tests (all passing).

Lua API (crates/jarvis-core/src/lua/api/scheduler.rs)
  - jarvis.scheduler.add({name, schedule, action={type, text|script_path}})
  - jarvis.scheduler.{list, count, remove(id), clear}.
  - Tasks come back as {id, name, schedule_human, action, enabled, last_fired}.

Wire-up (crates/jarvis-app/src/main.rs)
  - scheduler::init() + scheduler::start_background() after profiles::init.
  - Tasks survive restarts via schedule.json.

Voice commands (resources/commands/scheduler/, 6 ids)
  - scheduler.add_reminder    "напомни через 5 минут выключить кофеварку"
  - scheduler.add_at          "напомни в 18:00 забрать ребёнка"
  - scheduler.add_recurring   "каждые 2 часа напоминай попить воды"
  - scheduler.add_daily       "каждый день в 9:00 делай briefing"
  - scheduler.list            "что у меня запланировано"
  - scheduler.clear           "очисти расписание"

Russian-aware parsers (час/часа/часов, минут/минуту/минуты) live inside the Lua
packs — easy to extend without touching Rust.

Tests: 31/31 jarvis-core unit tests pass (24 prior + 7 scheduler).
Build: cargo build --release -p jarvis-app and -p jarvis-gui both green.
This commit is contained in:
Bossiara13 2026-05-15 15:43:04 +03:00
parent 0b1f1d4480
commit 12b1ed4ccb
13 changed files with 1038 additions and 1 deletions

View file

@ -56,6 +56,8 @@ pub mod long_term_memory;
pub mod profiles;
pub mod scheduler;
#[cfg(feature = "llm")]
pub mod llm;

View file

@ -10,4 +10,5 @@ pub mod llm;
pub mod text;
pub mod memory;
pub mod profile;
pub mod vision;
pub mod vision;
pub mod scheduler;

View file

@ -0,0 +1,144 @@
//! Lua bindings for the proactive scheduler.
//!
//! Usage:
//! jarvis.scheduler.add({
//! name = "Daily briefing",
//! schedule = "daily 09:00",
//! action = { type = "speak", text = "Доброе утро. Готов к работе." }
//! })
//!
//! jarvis.scheduler.add({
//! name = "Reminder",
//! schedule = "in 5 minutes",
//! action = { type = "speak", text = "Выключи кофеварку." }
//! })
//!
//! for _, t in ipairs(jarvis.scheduler.list()) do
//! print(t.id, t.name, t.schedule_human)
//! end
//!
//! jarvis.scheduler.remove(id)
//! jarvis.scheduler.clear()
//!
//! Schedule string syntax (see `scheduler::Schedule::parse`):
//! "daily HH:MM" | "at HH:MM" | "every N minutes" | "every N hours" | "in N minutes" | "in N hours"
use mlua::{Lua, Table, Value};
use crate::scheduler::{self, Action, Schedule, ScheduledTask};
pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> {
let sched = lua.create_table()?;
let add_fn = lua.create_function(|_, t: Table| {
let name: String = t.get::<Option<String>>("name")?.unwrap_or_else(|| "task".to_string());
let schedule_str: String = t.get::<String>("schedule")?;
let id: String = t.get::<Option<String>>("id")?.unwrap_or_default();
let action_tbl: Table = t.get::<Table>("action")?;
let action_type: String = action_tbl.get::<String>("type")?;
let action = match action_type.as_str() {
"speak" => Action::Speak {
text: action_tbl.get::<String>("text").unwrap_or_default(),
},
"lua" => Action::Lua {
script_path: action_tbl.get::<String>("script_path").unwrap_or_default(),
},
other => return Err(mlua::Error::external(format!("unknown action type: {}", other))),
};
let schedule = Schedule::parse(&schedule_str)
.map_err(|e| mlua::Error::external(format!("bad schedule '{}': {}", schedule_str, e)))?;
let task = ScheduledTask {
id,
name,
schedule,
action,
last_fired: None,
enabled: true,
created_at: 0,
};
scheduler::add(task).map_err(mlua::Error::external)
})?;
sched.set("add", add_fn)?;
let remove_fn = lua.create_function(|_, id: String| {
Ok(scheduler::remove(&id))
})?;
sched.set("remove", remove_fn)?;
let clear_fn = lua.create_function(|_, ()| {
Ok(scheduler::clear())
})?;
sched.set("clear", clear_fn)?;
let list_fn = lua.create_function(|lua, ()| {
let arr = lua.create_table()?;
for (i, t) in scheduler::list().iter().enumerate() {
arr.set(i + 1, task_to_lua(lua, t)?)?;
}
Ok(arr)
})?;
sched.set("list", list_fn)?;
let count_fn = lua.create_function(|_, ()| {
Ok(scheduler::list().len())
})?;
sched.set("count", count_fn)?;
jarvis.set("scheduler", sched)?;
Ok(())
}
fn task_to_lua(lua: &Lua, t: &ScheduledTask) -> mlua::Result<Table> {
let tbl = lua.create_table()?;
tbl.set("id", t.id.clone())?;
tbl.set("name", t.name.clone())?;
tbl.set("enabled", t.enabled)?;
tbl.set("created_at", t.created_at)?;
tbl.set("schedule_human", schedule_human(&t.schedule))?;
let last_fired: Value = match t.last_fired {
Some(ts) => Value::Integer(ts),
None => Value::Nil,
};
tbl.set("last_fired", last_fired)?;
let action_tbl = lua.create_table()?;
match &t.action {
Action::Speak { text } => {
action_tbl.set("type", "speak")?;
action_tbl.set("text", text.clone())?;
}
Action::Lua { script_path } => {
action_tbl.set("type", "lua")?;
action_tbl.set("script_path", script_path.clone())?;
}
}
tbl.set("action", action_tbl)?;
Ok(tbl)
}
fn schedule_human(s: &Schedule) -> String {
match s {
Schedule::Daily { hour, minute } => format!("каждый день в {:02}:{:02}", hour, minute),
Schedule::Interval { seconds } => {
if *seconds % 3600 == 0 {
format!("каждые {} часов", seconds / 3600)
} else if *seconds % 60 == 0 {
format!("каждые {} минут", seconds / 60)
} else {
format!("каждые {} секунд", seconds)
}
}
Schedule::Once { at } => {
chrono::DateTime::from_timestamp(*at, 0)
.map(|dt: chrono::DateTime<chrono::Utc>| {
dt.with_timezone(&chrono::Local).format("один раз в %H:%M %d.%m").to_string()
})
.unwrap_or_else(|| format!("один раз в {}", at))
}
}
}

View file

@ -80,6 +80,7 @@ impl LuaEngine {
api::text::register(&self.lua, &jarvis)?;
api::memory::register(&self.lua, &jarvis)?;
api::profile::register(&self.lua, &jarvis)?;
api::scheduler::register(&self.lua, &jarvis)?;
// sandbox-controlled APIs
if self.sandbox.allows_http() {

View file

@ -0,0 +1,449 @@
//! IMBA-5: Proactive scheduler.
//!
//! Lets J.A.R.V.I.S. start the conversation on its own — fire reminders, run
//! a morning briefing routine at a fixed time, or nudge a habit at intervals.
//! No other Russian voice assistant on the desktop does this well.
//!
//! Storage: JSON at `<APP_CONFIG_DIR>/schedule.json`. Atomic write-through.
//! Background thread ticks every 60 seconds (default) and fires due tasks.
//! Tasks are deduplicated by id; same id replaces.
//!
//! Schedule formats (parsed by `Schedule::parse`):
//! - "daily HH:MM" e.g. "daily 09:00"
//! - "every N minutes" e.g. "every 30 minutes"
//! - "every N hours" e.g. "every 2 hours"
//! - "in N minutes" e.g. "in 5 minutes" (one-shot)
//! - "in N hours" e.g. "in 1 hours" (one-shot)
//! - "at HH:MM" (today only; one-shot)
//!
//! Action types:
//! - speak — TTS the message via `crate::tts::speak_default`
//! - lua — run a Lua script at the given path (sandboxed standard)
//! - command — emit IPC message to trigger a known command (TODO)
//!
//! Lua bindings (registered separately): `jarvis.scheduler.{add, list, remove, clear}`.
use chrono::{Datelike, Local, Timelike};
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::APP_CONFIG_DIR;
const FILE_NAME: &str = "schedule.json";
const TICK_INTERVAL_SECS: u64 = 30;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Schedule {
/// Fires every day at the given local hour:minute.
Daily { hour: u32, minute: u32 },
/// Fires every `seconds` from `anchor` (last fire or task creation).
Interval { seconds: u64 },
/// Fires once at the given UNIX timestamp (seconds), then disables itself.
Once { at: i64 },
}
impl Schedule {
pub fn parse(spec: &str) -> Result<Self, String> {
let s = spec.trim().to_lowercase();
// "daily HH:MM"
if let Some(rest) = s.strip_prefix("daily") {
return parse_hhmm(rest.trim()).map(|(h, m)| Schedule::Daily { hour: h, minute: m });
}
// "at HH:MM" → Once today
if let Some(rest) = s.strip_prefix("at") {
let (h, m) = parse_hhmm(rest.trim())?;
let now = Local::now();
let mut at = now
.with_hour(h)
.and_then(|t| t.with_minute(m))
.and_then(|t| t.with_second(0))
.ok_or_else(|| "invalid time".to_string())?;
if at <= now {
at = at + chrono::Duration::days(1);
}
return Ok(Schedule::Once { at: at.timestamp() });
}
// "every N minutes" / "every N hours"
if let Some(rest) = s.strip_prefix("every") {
let parts: Vec<&str> = rest.trim().split_whitespace().collect();
if parts.len() == 2 {
let n: u64 = parts[0].parse().map_err(|_| format!("bad number: {}", parts[0]))?;
let secs = match parts[1] {
"minute" | "minutes" | "минут" | "минуту" | "минуты" => n * 60,
"hour" | "hours" | "час" | "часа" | "часов" => n * 3600,
"second" | "seconds" | "секунд" | "секунду" | "секунды" => n,
other => return Err(format!("unknown unit: {}", other)),
};
return Ok(Schedule::Interval { seconds: secs });
}
}
// "in N minutes" / "in N hours"
if let Some(rest) = s.strip_prefix("in") {
let parts: Vec<&str> = rest.trim().split_whitespace().collect();
if parts.len() == 2 {
let n: i64 = parts[0].parse().map_err(|_| format!("bad number: {}", parts[0]))?;
let secs = match parts[1] {
"minute" | "minutes" | "минут" | "минуту" | "минуты" => n * 60,
"hour" | "hours" | "час" | "часа" | "часов" => n * 3600,
"second" | "seconds" | "секунд" | "секунду" | "секунды" => n,
other => return Err(format!("unknown unit: {}", other)),
};
let at = Local::now().timestamp() + secs;
return Ok(Schedule::Once { at });
}
}
Err(format!("unknown schedule spec: {}", spec))
}
/// Returns the next UNIX timestamp this schedule should fire at, given the
/// most recent fire time (or task creation time).
pub fn next_fire(&self, last_fired: Option<i64>, now: i64) -> Option<i64> {
match self {
Schedule::Daily { hour, minute } => {
let today = Local::now()
.with_hour(*hour)?
.with_minute(*minute)?
.with_second(0)?;
let today_ts = today.timestamp();
if today_ts > now && last_fired.map_or(true, |lf| {
chrono::DateTime::from_timestamp(lf, 0).map_or(true, |dt: chrono::DateTime<chrono::Utc>| {
dt.with_timezone(&Local).day() != today.day()
})
}) {
Some(today_ts)
} else {
Some(today_ts + 86400)
}
}
Schedule::Interval { seconds } => {
let anchor = last_fired.unwrap_or(now);
Some(anchor + *seconds as i64)
}
Schedule::Once { at } => {
if last_fired.is_some() {
None
} else {
Some(*at)
}
}
}
}
}
fn parse_hhmm(s: &str) -> Result<(u32, u32), String> {
let mut it = s.splitn(2, ':');
let h: u32 = it.next().ok_or("missing hour")?.parse()
.map_err(|_| "bad hour".to_string())?;
let m: u32 = it.next().ok_or("missing minute")?.parse()
.map_err(|_| "bad minute".to_string())?;
if h > 23 || m > 59 {
return Err("hour/minute out of range".into());
}
Ok((h, m))
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Action {
/// Speak a plain text message via the active TTS backend.
Speak { text: String },
/// Run a Lua script (absolute path).
Lua { script_path: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledTask {
pub id: String,
pub name: String,
pub schedule: Schedule,
pub action: Action,
#[serde(default)]
pub last_fired: Option<i64>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub created_at: i64,
}
fn default_true() -> bool { true }
#[derive(Default, Debug, Serialize, Deserialize)]
struct Store {
#[serde(default)]
tasks: Vec<ScheduledTask>,
}
struct State {
path: PathBuf,
store: RwLock<Store>,
}
static STATE: OnceCell<State> = OnceCell::new();
static THREAD_RUNNING: AtomicBool = AtomicBool::new(false);
pub fn init() -> Result<(), String> {
if STATE.get().is_some() { return Ok(()); }
let dir = APP_CONFIG_DIR.get()
.ok_or_else(|| "APP_CONFIG_DIR not initialised".to_string())?;
let path = dir.join(FILE_NAME);
let store = load_or_empty(&path);
info!("Scheduler loaded: {} tasks from {}", store.tasks.len(), path.display());
STATE.set(State { path, store: RwLock::new(store) })
.map_err(|_| "scheduler already initialised".to_string())?;
Ok(())
}
fn load_or_empty(path: &PathBuf) -> Store {
if !path.is_file() { return Store::default(); }
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
warn!("Corrupt schedule file {}: {}", path.display(), e);
Store::default()
}),
Err(e) => {
warn!("Cannot read {}: {}", path.display(), e);
Store::default()
}
}
}
fn persist(state: &State) {
let store = state.store.read();
let tmp = state.path.with_extension("json.tmp");
let json = match serde_json::to_string_pretty(&*store) {
Ok(s) => s,
Err(e) => { warn!("Schedule serialize failed: {}", e); return; }
};
if let Err(e) = std::fs::write(&tmp, json) {
warn!("Schedule write failed: {}", e); return;
}
if let Err(e) = std::fs::rename(&tmp, &state.path) {
warn!("Schedule rename failed: {}", e);
}
}
fn now_secs() -> i64 {
Local::now().timestamp()
}
fn gen_id() -> String {
use std::time::SystemTime;
let micros = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_micros())
.unwrap_or(0);
format!("task-{}", micros)
}
/// Add (or replace by id) a scheduled task.
pub fn add(mut task: ScheduledTask) -> Result<String, String> {
let state = STATE.get().ok_or_else(|| "scheduler not init".to_string())?;
if task.id.is_empty() {
task.id = gen_id();
}
if task.created_at == 0 {
task.created_at = now_secs();
}
{
let mut store = state.store.write();
store.tasks.retain(|t| t.id != task.id);
store.tasks.push(task.clone());
}
persist(state);
info!("Scheduler: added task {} ({:?})", task.id, task.schedule);
Ok(task.id)
}
pub fn remove(id: &str) -> bool {
let Some(state) = STATE.get() else { return false; };
let removed = {
let mut store = state.store.write();
let before = store.tasks.len();
store.tasks.retain(|t| t.id != id);
before != store.tasks.len()
};
if removed {
persist(state);
info!("Scheduler: removed task {}", id);
}
removed
}
pub fn clear() -> usize {
let Some(state) = STATE.get() else { return 0; };
let n = {
let mut store = state.store.write();
let n = store.tasks.len();
store.tasks.clear();
n
};
persist(state);
info!("Scheduler: cleared {} tasks", n);
n
}
pub fn list() -> Vec<ScheduledTask> {
let Some(state) = STATE.get() else { return Vec::new(); };
state.store.read().tasks.clone()
}
pub fn find(id: &str) -> Option<ScheduledTask> {
let state = STATE.get()?;
state.store.read().tasks.iter().find(|t| t.id == id).cloned()
}
fn mark_fired(state: &State, id: &str, when: i64) -> bool {
let mut should_persist = false;
let mut should_remove = false;
{
let mut store = state.store.write();
if let Some(t) = store.tasks.iter_mut().find(|t| t.id == id) {
t.last_fired = Some(when);
should_persist = true;
if matches!(t.schedule, Schedule::Once { .. }) {
should_remove = true;
}
}
if should_remove {
store.tasks.retain(|t| t.id != id);
}
}
if should_persist {
persist(state);
}
should_remove
}
/// Compute due tasks (next_fire <= now and enabled).
fn due_tasks(state: &State, now: i64) -> Vec<ScheduledTask> {
let store = state.store.read();
store.tasks.iter()
.filter(|t| t.enabled)
.filter(|t| t.schedule.next_fire(t.last_fired, now).map_or(false, |nf| nf <= now))
.cloned()
.collect()
}
/// Start the background tick thread. Idempotent.
pub fn start_background() {
if THREAD_RUNNING.swap(true, Ordering::SeqCst) {
return;
}
std::thread::Builder::new()
.name("jarvis-scheduler".into())
.spawn(|| {
info!("Scheduler thread started (tick every {}s).", TICK_INTERVAL_SECS);
loop {
std::thread::sleep(Duration::from_secs(TICK_INTERVAL_SECS));
tick();
}
})
.ok();
}
fn tick() {
let Some(state) = STATE.get() else { return; };
let now = now_secs();
let due = due_tasks(state, now);
for task in due {
info!("Scheduler: firing {} '{}'", task.id, task.name);
fire_action(&task.action);
mark_fired(state, &task.id, now);
}
}
fn fire_action(action: &Action) {
match action {
Action::Speak { text } => {
// Play "reply" sound first so the user knows it's the scheduler.
crate::voices::play_reply();
std::thread::sleep(Duration::from_millis(400));
crate::tts::speak_default(text);
}
Action::Lua { script_path } => {
#[cfg(feature = "lua")]
run_lua_script(script_path);
#[cfg(not(feature = "lua"))]
warn!("Lua feature disabled — cannot run {}", script_path);
}
}
}
#[cfg(feature = "lua")]
fn run_lua_script(script_path: &str) {
use crate::lua::{LuaEngine, SandboxLevel, CommandContext};
use std::path::Path;
let path = Path::new(script_path).to_path_buf();
if !path.is_file() {
warn!("Scheduler: lua script not found: {}", path.display());
return;
}
let engine = match LuaEngine::new(SandboxLevel::Standard) {
Ok(e) => e,
Err(e) => { warn!("Scheduler: lua engine init: {:?}", e); return; }
};
let ctx = CommandContext {
phrase: String::new(),
command_id: "scheduler".into(),
command_path: path.parent().map(|p| p.to_path_buf()).unwrap_or_default(),
language: crate::i18n::get_language(),
slots: None,
};
match engine.execute(&path, ctx, Duration::from_secs(30)) {
Ok(_) => {}
Err(e) => warn!("Scheduler: lua exec failed: {:?}", e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_daily() {
let s = Schedule::parse("daily 09:00").unwrap();
assert_eq!(s, Schedule::Daily { hour: 9, minute: 0 });
}
#[test]
fn parse_interval_minutes() {
let s = Schedule::parse("every 30 minutes").unwrap();
assert_eq!(s, Schedule::Interval { seconds: 1800 });
}
#[test]
fn parse_interval_russian() {
let s = Schedule::parse("every 2 часа").unwrap();
assert_eq!(s, Schedule::Interval { seconds: 7200 });
}
#[test]
fn parse_in_minutes() {
let s = Schedule::parse("in 5 minutes").unwrap();
matches!(s, Schedule::Once { .. });
}
#[test]
fn parse_bad_unit() {
assert!(Schedule::parse("every 5 weeks").is_err());
}
#[test]
fn interval_next_fire() {
let s = Schedule::Interval { seconds: 60 };
assert_eq!(s.next_fire(Some(100), 1000), Some(160));
}
#[test]
fn once_disables_after_fire() {
let s = Schedule::Once { at: 500 };
assert_eq!(s.next_fire(None, 1000), Some(500));
assert_eq!(s.next_fire(Some(500), 1000), None);
}
}