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

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

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

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

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

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

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

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

View 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 = ["очисти розклад", "скасуй всі нагадування"]

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