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:
parent
0b1f1d4480
commit
12b1ed4ccb
13 changed files with 1038 additions and 1 deletions
|
|
@ -73,6 +73,13 @@ fn main() -> Result<(), String> {
|
|||
warn!("Profiles init failed: {}", e);
|
||||
}
|
||||
|
||||
eprintln!("[jarvis-app] step: scheduler::init + background");
|
||||
if let Err(e) = jarvis_core::scheduler::init() {
|
||||
warn!("Scheduler init failed: {}", e);
|
||||
} else {
|
||||
jarvis_core::scheduler::start_background();
|
||||
}
|
||||
|
||||
eprintln!("[jarvis-app] step: recorder::init");
|
||||
if recorder::init().is_err() {
|
||||
notify_mic_problem();
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ pub mod long_term_memory;
|
|||
|
||||
pub mod profiles;
|
||||
|
||||
pub mod scheduler;
|
||||
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod llm;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
144
crates/jarvis-core/src/lua/api/scheduler.rs
Normal file
144
crates/jarvis-core/src/lua/api/scheduler.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
449
crates/jarvis-core/src/scheduler.rs
Normal file
449
crates/jarvis-core/src/scheduler.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
58
resources/commands/scheduler/add_at.lua
Normal file
58
resources/commands/scheduler/add_at.lua
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
-- "напомни в 18:00 забрать ребёнка"
|
||||
local phrase = (jarvis.context.phrase or ""):lower()
|
||||
|
||||
local body = jarvis.text.strip_trigger(phrase, {
|
||||
"напомни сегодня в",
|
||||
"напомни в",
|
||||
"разбуди в",
|
||||
"поставь напоминалку на",
|
||||
"remind me at",
|
||||
"wake me at",
|
||||
"нагадай о",
|
||||
"розбуди о",
|
||||
})
|
||||
|
||||
body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
|
||||
-- Parse "HH:MM <text>" or "HH MM <text>"
|
||||
local h, m, rest = body:match("^(%d%d?)%s*[:%-%s]%s*(%d%d?)%s*(.*)$")
|
||||
if not h then
|
||||
h, rest = body:match("^(%d%d?)%s*(.*)$")
|
||||
if not h then
|
||||
jarvis.speak("Не понял время.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
m = "00"
|
||||
end
|
||||
m = m or "00"
|
||||
|
||||
local hn = tonumber(h)
|
||||
local mn = tonumber(m)
|
||||
if hn < 0 or hn > 23 or mn < 0 or mn > 59 then
|
||||
jarvis.speak("Время вне диапазона.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
local schedule = string.format("at %02d:%02d", hn, mn)
|
||||
local text_body = (rest or ""):gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
if text_body == "" then text_body = "Напоминание." end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
name = "Reminder",
|
||||
schedule = schedule,
|
||||
action = { type = "speak", text = text_body }
|
||||
})
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.log("warn", "scheduler.add: " .. tostring(err))
|
||||
jarvis.speak("Не получилось.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
jarvis.speak(string.format("Напомню в %02d:%02d.", hn, mn))
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
62
resources/commands/scheduler/add_daily.lua
Normal file
62
resources/commands/scheduler/add_daily.lua
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
-- "каждый день в 9:00 делай briefing" / "каждое утро в 8 напоминай зарядку"
|
||||
local phrase = (jarvis.context.phrase or ""):lower()
|
||||
|
||||
local body = jarvis.text.strip_trigger(phrase, {
|
||||
"каждое утро в",
|
||||
"каждый день в",
|
||||
"ежедневно в",
|
||||
"по утрам в",
|
||||
"every day at",
|
||||
"daily at",
|
||||
"щодня о",
|
||||
"кожен день о",
|
||||
})
|
||||
|
||||
body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
|
||||
-- Parse "HH:MM <text>" or "HH <text>"
|
||||
local h, m, rest = body:match("^(%d%d?)%s*[:%-]%s*(%d%d?)%s*(.*)$")
|
||||
if not h then
|
||||
h, rest = body:match("^(%d%d?)%s*(.*)$")
|
||||
m = "00"
|
||||
end
|
||||
|
||||
if not h then
|
||||
jarvis.speak("Не понял время.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
local hn = tonumber(h)
|
||||
local mn = tonumber(m) or 0
|
||||
if hn < 0 or hn > 23 or mn < 0 or mn > 59 then
|
||||
jarvis.speak("Время вне диапазона.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
local schedule = string.format("daily %02d:%02d", hn, mn)
|
||||
local text_body = (rest or ""):gsub("^напоминай%s+", "")
|
||||
:gsub("^напомни%s+", "")
|
||||
:gsub("^делай%s+", "")
|
||||
:gsub("^[%s,:%.]+", "")
|
||||
:gsub("%s+$", "")
|
||||
if text_body == "" then text_body = "Доброе утро." end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
name = "Daily",
|
||||
schedule = schedule,
|
||||
action = { type = "speak", text = text_body }
|
||||
})
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.log("warn", "scheduler.add: " .. tostring(err))
|
||||
jarvis.speak("Не получилось.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
jarvis.speak(string.format("Каждый день в %02d:%02d.", hn, mn))
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
74
resources/commands/scheduler/add_recurring.lua
Normal file
74
resources/commands/scheduler/add_recurring.lua
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
-- "каждые 2 часа напоминай попить воды" / "каждый час напоминай размяться"
|
||||
local phrase = (jarvis.context.phrase or ""):lower()
|
||||
|
||||
local body = jarvis.text.strip_trigger(phrase, {
|
||||
"напоминай каждые",
|
||||
"напоминай каждый",
|
||||
"каждые",
|
||||
"каждый час",
|
||||
"remind me every",
|
||||
"every",
|
||||
"кожні",
|
||||
"нагадуй кожні",
|
||||
})
|
||||
|
||||
body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
|
||||
-- Parse "<N> <unit> <verb> <text>" or "час <text>" (implicit 1)
|
||||
local n_str, unit, rest = body:match("^(%d+)%s+(%S+)%s*(.*)$")
|
||||
local n, unit_norm, sample
|
||||
|
||||
if n_str then
|
||||
n = tonumber(n_str)
|
||||
if unit:match("^минут") or unit:match("^minute") then
|
||||
unit_norm = "minutes"
|
||||
sample = n .. " минут"
|
||||
elseif unit:match("^час") or unit:match("^hour") then
|
||||
unit_norm = "hours"
|
||||
sample = n .. " час" .. (n == 1 and "" or (n < 5 and "а" or "ов"))
|
||||
else
|
||||
jarvis.speak("Не понял единицу.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
else
|
||||
-- "каждый час напоминай..."
|
||||
local single, rest2 = body:match("^(%a+)%s*(.*)$")
|
||||
if single and (single == "час" or single == "минуту" or single == "минута") then
|
||||
n = 1
|
||||
unit_norm = (single == "час") and "hours" or "minutes"
|
||||
sample = (single == "час") and "час" or "минуту"
|
||||
rest = rest2
|
||||
else
|
||||
jarvis.speak("Не понял через сколько повторять.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
end
|
||||
|
||||
-- Strip optional reminder verb
|
||||
local text_body = (rest or ""):gsub("^напоминай%s+", "")
|
||||
:gsub("^напомни%s+", "")
|
||||
:gsub("^remind me%s+", "")
|
||||
:gsub("^[%s,:%.]+", "")
|
||||
:gsub("%s+$", "")
|
||||
if text_body == "" then text_body = "Напоминание." end
|
||||
|
||||
local schedule = string.format("every %d %s", n, unit_norm)
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
name = "Recurring",
|
||||
schedule = schedule,
|
||||
action = { type = "speak", text = text_body }
|
||||
})
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.log("warn", "scheduler.add: " .. tostring(err))
|
||||
jarvis.speak("Не получилось.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
jarvis.speak("Буду напоминать каждые " .. sample .. ".")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
102
resources/commands/scheduler/add_reminder.lua
Normal file
102
resources/commands/scheduler/add_reminder.lua
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
-- "напомни мне через 5 минут выключить кофеварку"
|
||||
local phrase = (jarvis.context.phrase or ""):lower()
|
||||
|
||||
-- Strip the trigger
|
||||
local body = jarvis.text.strip_trigger(phrase, {
|
||||
"напомни мне через",
|
||||
"напомни через",
|
||||
"поставь напоминалку через",
|
||||
"поставь напоминание через",
|
||||
"разбуди через",
|
||||
"remind me in",
|
||||
"set a reminder in",
|
||||
"нагадай через",
|
||||
"постав нагадування через",
|
||||
})
|
||||
|
||||
body = body:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
|
||||
if body == "" then
|
||||
jarvis.speak("Через сколько и про что напомнить?")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
-- Parse "<N> <unit> <text>"
|
||||
-- N can be digit or word (один, два, ...) — for v1 only digits
|
||||
local n_str, unit, rest = body:match("^(%d+)%s+(%S+)%s*(.*)$")
|
||||
if not n_str then
|
||||
-- maybe "час" / "минуту" (singular)
|
||||
local single_unit, rest2 = body:match("^([%a]+)%s*(.*)$")
|
||||
if single_unit then
|
||||
local map = {
|
||||
["час"] = "1 hours",
|
||||
["минуту"] = "1 minutes",
|
||||
["секунду"] = "1 seconds",
|
||||
}
|
||||
if map[single_unit] then
|
||||
local schedule = "in " .. map[single_unit]
|
||||
local text_body = rest2:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
if text_body == "" then text_body = "Напоминание." end
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
name = "Reminder",
|
||||
schedule = schedule,
|
||||
action = { type = "speak", text = text_body }
|
||||
})
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.log("warn", "scheduler.add: " .. tostring(err))
|
||||
jarvis.speak("Не получилось.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
jarvis.speak("Напомню через " .. single_unit .. ".")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
end
|
||||
end
|
||||
jarvis.speak("Не понял через сколько. Скажите например: через 5 минут.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
local n = tonumber(n_str)
|
||||
local unit_norm
|
||||
local sample
|
||||
if unit:match("^минут") or unit == "минуты" or unit == "минуту" or unit:match("^minute") then
|
||||
unit_norm = "minutes"
|
||||
sample = n .. " минут"
|
||||
elseif unit:match("^час") or unit:match("^hour") then
|
||||
unit_norm = "hours"
|
||||
sample = n .. " час" .. (n == 1 and "" or (n < 5 and "а" or "ов"))
|
||||
elseif unit:match("^секунд") or unit:match("^second") then
|
||||
unit_norm = "seconds"
|
||||
sample = n .. " секунд"
|
||||
else
|
||||
jarvis.speak("Не понял единицу. Минуты или часы.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
local schedule = string.format("in %d %s", n, unit_norm)
|
||||
local text_body = rest:gsub("^[%s,:%.]+", ""):gsub("%s+$", "")
|
||||
if text_body == "" then text_body = "Напоминание." end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
jarvis.scheduler.add({
|
||||
name = "Reminder",
|
||||
schedule = schedule,
|
||||
action = { type = "speak", text = text_body }
|
||||
})
|
||||
end)
|
||||
if not ok then
|
||||
jarvis.log("warn", "scheduler.add: " .. tostring(err))
|
||||
jarvis.speak("Не получилось добавить напоминание.")
|
||||
jarvis.audio.play_error()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
jarvis.speak("Напомню через " .. sample .. ".")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
8
resources/commands/scheduler/clear.lua
Normal file
8
resources/commands/scheduler/clear.lua
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
local n = jarvis.scheduler.clear()
|
||||
if n == 0 then
|
||||
jarvis.speak("В расписании и так пусто.")
|
||||
else
|
||||
jarvis.speak(string.format("Удалил %d задач.", n))
|
||||
end
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
109
resources/commands/scheduler/command.toml
Normal file
109
resources/commands/scheduler/command.toml
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# IMBA-5: Proactive scheduler — recurring reminders, daily briefings, intervals.
|
||||
|
||||
[[commands]]
|
||||
id = "scheduler.add_reminder"
|
||||
type = "lua"
|
||||
script = "add_reminder.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"напомни мне через", "напомни через",
|
||||
"поставь напоминалку через", "поставь напоминание через",
|
||||
"разбуди через",
|
||||
]
|
||||
en = ["remind me in", "set a reminder in"]
|
||||
ua = ["нагадай через", "постав нагадування через"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "scheduler.add_at"
|
||||
type = "lua"
|
||||
script = "add_at.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"напомни в",
|
||||
"напомни сегодня в",
|
||||
"разбуди в",
|
||||
"поставь напоминалку на",
|
||||
]
|
||||
en = ["remind me at", "wake me at"]
|
||||
ua = ["нагадай о", "розбуди о"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "scheduler.add_recurring"
|
||||
type = "lua"
|
||||
script = "add_recurring.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"каждые",
|
||||
"каждый час",
|
||||
"каждые два часа",
|
||||
"напоминай каждые",
|
||||
"напоминай каждый",
|
||||
]
|
||||
en = ["every", "remind me every"]
|
||||
ua = ["кожні", "нагадуй кожні"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "scheduler.add_daily"
|
||||
type = "lua"
|
||||
script = "add_daily.lua"
|
||||
sandbox = "standard"
|
||||
timeout = 5000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"каждый день в",
|
||||
"каждое утро в",
|
||||
"ежедневно в",
|
||||
"по утрам в",
|
||||
]
|
||||
en = ["every day at", "daily at"]
|
||||
ua = ["щодня о", "кожен день о"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "scheduler.list"
|
||||
type = "lua"
|
||||
script = "list.lua"
|
||||
sandbox = "minimal"
|
||||
timeout = 3000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"что у меня запланировано",
|
||||
"какие напоминания",
|
||||
"покажи расписание",
|
||||
"какие задачи у меня",
|
||||
"что в расписании",
|
||||
]
|
||||
en = ["what's on my schedule", "show schedule", "list reminders"]
|
||||
ua = ["що в розкладі", "покажи розклад"]
|
||||
|
||||
|
||||
[[commands]]
|
||||
id = "scheduler.clear"
|
||||
type = "lua"
|
||||
script = "clear.lua"
|
||||
sandbox = "minimal"
|
||||
timeout = 3000
|
||||
|
||||
[commands.phrases]
|
||||
ru = [
|
||||
"очисти расписание",
|
||||
"удали все напоминания",
|
||||
"отмени все напоминания",
|
||||
"сбрось расписание",
|
||||
]
|
||||
en = ["clear schedule", "cancel all reminders"]
|
||||
ua = ["очисти розклад", "скасуй всі нагадування"]
|
||||
20
resources/commands/scheduler/list.lua
Normal file
20
resources/commands/scheduler/list.lua
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
local tasks = jarvis.scheduler.list()
|
||||
if #tasks == 0 then
|
||||
jarvis.speak("Расписание пустое.")
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
end
|
||||
|
||||
local sample = math.min(#tasks, 5)
|
||||
local line = string.format("Запланировано %d. ", #tasks)
|
||||
for i = 1, sample do
|
||||
local t = tasks[i]
|
||||
local what = (t.action and t.action.text) or t.name or "задача"
|
||||
line = line .. t.schedule_human .. ": " .. what
|
||||
if i < sample then line = line .. ". " end
|
||||
end
|
||||
line = line .. "."
|
||||
|
||||
jarvis.speak(line)
|
||||
jarvis.audio.play_ok()
|
||||
return { chain = false }
|
||||
Loading…
Add table
Add a link
Reference in a new issue