From dbb4a6b8886a67eee303d96d31353e98c7c5633e Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 17:12:32 +0300 Subject: [PATCH 1/2] feat(macros): IMBA-7 voice-recorded command sequences (VoiceAttack-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simpler than AHK keyboard hooks: record what the user SAID, replay each phrase through the normal dispatch path. Works for any command jarvis already knows. Flow: "Запиши макрос работа" → start_recording("работа") "Открой браузер" → buffered into the recording "Запусти спотифай" → buffered "Режим работа" → buffered "Сохрани макрос" → save_recording() → persist ...later... "Запусти макрос работа" → replay("работа") → fires each phrase in order Core (crates/jarvis-core/src/macros.rs) - Macro {name, steps, created_at, last_run} persisted at /macros.json with atomic write-through. - Recording state in-memory: RwLock>. - start_recording / record_step / save_recording / cancel_recording. - list / get / delete / mark_run. - replay(name) spawns a thread that fires each step through a callback, with 800ms inter-step delay. Caller returns immediately. - is_macro_control filter prevents recording the meta-commands themselves (no infinite recursion when "запиши макрос" runs during a recording). - 3 unit tests (55 total). Wiring (crates/jarvis-app/src/main.rs) - macros::init() before listener starts. - macros::set_replay_callback(move |phrase| text_cmd_tx.send(phrase)) — each replay step gets queued as a synthetic text command, processed by the same dispatcher the GUI uses. No special-case code path. Recording hook (crates/jarvis-app/src/app.rs::execute_command) - On successful command execution → call macros::record_step(text). - Failed commands are NOT recorded (would fail on replay too). - is_macro_control filter inside record_step skips meta-commands. Lua API (crates/jarvis-core/src/lua/api/macros.rs) - jarvis.macros.{start, save, cancel, replay, list, delete, is_recording, recording_name} Voice pack (resources/commands/macros/, 6 commands) - "запиши макрос NAME" → macros.start_recording - "сохрани макрос" → macros.save - "отмени макрос" → macros.cancel - "запусти макрос NAME" → macros.replay - "какие у меня макросы" → macros.list - "удали макрос NAME" → macros.delete Build: cargo build --release -p jarvis-app green. 55/55 tests pass. --- crates/jarvis-app/src/app.rs | 5 + crates/jarvis-app/src/main.rs | 16 +- crates/jarvis-core/src/lib.rs | 2 + crates/jarvis-core/src/lua/api.rs | 3 +- crates/jarvis-core/src/lua/api/macros.rs | 70 ++++++ crates/jarvis-core/src/lua/engine.rs | 1 + crates/jarvis-core/src/macros.rs | 303 +++++++++++++++++++++++ resources/commands/macros/cancel.lua | 4 + resources/commands/macros/command.toml | 107 ++++++++ resources/commands/macros/delete.lua | 18 ++ resources/commands/macros/list.lua | 12 + resources/commands/macros/replay.lua | 24 ++ resources/commands/macros/save.lua | 8 + resources/commands/macros/start.lua | 23 ++ 14 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 crates/jarvis-core/src/lua/api/macros.rs create mode 100644 crates/jarvis-core/src/macros.rs create mode 100644 resources/commands/macros/cancel.lua create mode 100644 resources/commands/macros/command.toml create mode 100644 resources/commands/macros/delete.lua create mode 100644 resources/commands/macros/list.lua create mode 100644 resources/commands/macros/replay.lua create mode 100644 resources/commands/macros/save.lua create mode 100644 resources/commands/macros/start.lua diff --git a/crates/jarvis-app/src/app.rs b/crates/jarvis-app/src/app.rs index afa4f5d..032e0e6 100644 --- a/crates/jarvis-app/src/app.rs +++ b/crates/jarvis-app/src/app.rs @@ -464,6 +464,11 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool { info!("Command executed successfully"); // voices::play_ok(); voices::play_random_from(cmd_config.get_sounds(&i18n::get_language()).as_slice()); + + // IMBA-7: if macro recording is active, capture this phrase. + // Skipped for macro-control commands themselves (filter inside record_step). + jarvis_core::macros::record_step(text); + ipc::send(IpcEvent::CommandExecuted { id: cmd_config.id.clone(), success: true, diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 86d831b..3e888ed 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -88,6 +88,11 @@ fn main() -> Result<(), String> { jarvis_core::scheduler::start_background(); } + eprintln!("[jarvis-app] step: macros::init"); + if let Err(e) = jarvis_core::macros::init() { + warn!("Macros init failed: {}", e); + } + eprintln!("[jarvis-app] step: recorder::init"); if recorder::init().is_err() { notify_mic_problem(); @@ -153,9 +158,18 @@ fn main() -> Result<(), String> { info!("Initializing IPC..."); ipc::init(); - // channel for text commands (manually written in the GUI) + // channel for text commands (manually written in the GUI, OR fed by macro replay) let (text_cmd_tx, text_cmd_rx) = mpsc::channel::(); + // Wire macro replay through the same text-command channel: each step of a + // replayed macro becomes a synthetic text command, processed in order. + let macro_tx = text_cmd_tx.clone(); + jarvis_core::macros::set_replay_callback(Box::new(move |phrase: &str| { + if let Err(e) = macro_tx.send(phrase.to_string()) { + warn!("Macro replay: failed to enqueue '{}': {}", phrase, e); + } + })); + ipc::set_action_handler(move |action| { match action { IpcAction::Stop => { diff --git a/crates/jarvis-core/src/lib.rs b/crates/jarvis-core/src/lib.rs index b07b796..7ed72d1 100644 --- a/crates/jarvis-core/src/lib.rs +++ b/crates/jarvis-core/src/lib.rs @@ -60,6 +60,8 @@ pub mod profiles; pub mod scheduler; +pub mod macros; + #[cfg(feature = "llm")] pub mod llm; diff --git a/crates/jarvis-core/src/lua/api.rs b/crates/jarvis-core/src/lua/api.rs index 22b0326..9634b16 100644 --- a/crates/jarvis-core/src/lua/api.rs +++ b/crates/jarvis-core/src/lua/api.rs @@ -13,4 +13,5 @@ pub mod profile; pub mod vision; pub mod scheduler; pub mod cmd; -pub mod health; \ No newline at end of file +pub mod health; +pub mod macros; \ No newline at end of file diff --git a/crates/jarvis-core/src/lua/api/macros.rs b/crates/jarvis-core/src/lua/api/macros.rs new file mode 100644 index 0000000..89d0657 --- /dev/null +++ b/crates/jarvis-core/src/lua/api/macros.rs @@ -0,0 +1,70 @@ +//! Lua bindings for the macro recorder. +//! +//! Usage from Lua: +//! jarvis.macros.start("работа") -- begin recording +//! jarvis.macros.save() -- persist + stop +//! jarvis.macros.cancel() -- abort without saving +//! jarvis.macros.replay("работа") -- play back; returns step count or nil +//! jarvis.macros.list() -- array of {name, steps_count, last_run} +//! jarvis.macros.delete(name) -- removes +//! jarvis.macros.is_recording() -- bool +//! jarvis.macros.recording_name() -- string or nil + +use mlua::{Lua, Table, Value}; + +use crate::macros; + +pub fn register(lua: &Lua, jarvis: &Table) -> mlua::Result<()> { + let m = lua.create_table()?; + + m.set("start", lua.create_function(|_, name: String| { + macros::start_recording(&name).map_err(mlua::Error::external) + })?)?; + + m.set("save", lua.create_function(|_, ()| { + macros::save_recording().map_err(mlua::Error::external) + })?)?; + + m.set("cancel", lua.create_function(|_, ()| { + Ok(macros::cancel_recording()) + })?)?; + + m.set("is_recording", lua.create_function(|_, ()| { + Ok(macros::is_recording()) + })?)?; + + m.set("recording_name", lua.create_function(|lua, ()| { + match macros::recording_name() { + Some(n) => Ok(Value::String(lua.create_string(n)?)), + None => Ok(Value::Nil), + } + })?)?; + + m.set("replay", lua.create_function(|_, name: String| { + macros::replay(&name).map_err(mlua::Error::external) + })?)?; + + m.set("delete", lua.create_function(|_, name: String| { + Ok(macros::delete(&name)) + })?)?; + + m.set("list", lua.create_function(|lua, ()| { + let arr = lua.create_table()?; + for (i, mac) in macros::list().iter().enumerate() { + let row = lua.create_table()?; + row.set("name", mac.name.clone())?; + row.set("steps_count", mac.steps.len())?; + row.set("created_at", mac.created_at)?; + let last: Value = match mac.last_run { + Some(ts) => Value::Integer(ts), + None => Value::Nil, + }; + row.set("last_run", last)?; + arr.set(i + 1, row)?; + } + Ok(arr) + })?)?; + + jarvis.set("macros", m)?; + Ok(()) +} diff --git a/crates/jarvis-core/src/lua/engine.rs b/crates/jarvis-core/src/lua/engine.rs index a481b3e..91cf1cf 100644 --- a/crates/jarvis-core/src/lua/engine.rs +++ b/crates/jarvis-core/src/lua/engine.rs @@ -83,6 +83,7 @@ impl LuaEngine { api::scheduler::register(&self.lua, &jarvis)?; api::cmd::register(&self.lua, &jarvis)?; api::health::register(&self.lua, &jarvis)?; + api::macros::register(&self.lua, &jarvis)?; // sandbox-controlled APIs if self.sandbox.allows_http() { diff --git a/crates/jarvis-core/src/macros.rs b/crates/jarvis-core/src/macros.rs new file mode 100644 index 0000000..9c216a9 --- /dev/null +++ b/crates/jarvis-core/src/macros.rs @@ -0,0 +1,303 @@ +//! IMBA-7: Voice-command macros — record a sequence of commands once, +//! replay them later with one phrase. +//! +//! Simpler than AHK-style keyboard hooks: we record what the user SAID, then +//! replay each phrase through the normal dispatch path. Works for anything +//! that's already a known command (volume, media keys, profile switches, ...). +//! +//! Lifecycle: +//! +//! "Запиши макрос работа" → start_recording("работа") +//! "Открой браузер" → recorder buffers "открой браузер" +//! "Запусти спотифай" → recorder buffers +//! "Режим работа" → recorder buffers +//! "Сохрани макрос" → save() → persists to disk +//! +//! "Запусти макрос работа" → replay("работа") → fires each phrase in order, +//! with a small inter-step delay +//! +//! Storage: JSON at `/macros.json`. + +use once_cell::sync::OnceCell; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::APP_CONFIG_DIR; + +const FILE_NAME: &str = "macros.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Macro { + pub name: String, + pub steps: Vec, + #[serde(default)] + pub created_at: i64, + #[serde(default)] + pub last_run: Option, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +struct Store { + #[serde(default)] + macros: BTreeMap, +} + +#[derive(Debug)] +struct RecordingState { + name: String, + steps: Vec, +} + +struct State { + path: PathBuf, + store: RwLock, + recording: RwLock>, +} + +static STATE: OnceCell = OnceCell::new(); + +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!("Macros loaded: {} stored at {}", store.macros.len(), path.display()); + STATE.set(State { path, store: RwLock::new(store), recording: RwLock::new(None) }) + .map_err(|_| "macros 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 macros 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!("Macros serialize failed: {}", e); return; } + }; + if let Err(e) = std::fs::write(&tmp, json) { + warn!("Macros write failed: {}", e); return; + } + if let Err(e) = std::fs::rename(&tmp, &state.path) { + warn!("Macros rename failed: {}", e); + } +} + +fn now_secs() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn normalize_name(n: &str) -> String { + n.trim().to_lowercase() +} + +/// Begin recording a new macro. Replaces any in-progress recording. +pub fn start_recording(name: &str) -> Result<(), String> { + let state = STATE.get().ok_or_else(|| "macros not init".to_string())?; + let nname = normalize_name(name); + if nname.is_empty() { return Err("empty macro name".into()); } + *state.recording.write() = Some(RecordingState { + name: nname.clone(), + steps: Vec::new(), + }); + info!("Macros: recording started for '{}'", nname); + Ok(()) +} + +/// Buffer a phrase into the active recording (if any). Called by the command +/// dispatch hook after a command runs successfully. +pub fn record_step(phrase: &str) { + let Some(state) = STATE.get() else { return; }; + let mut rec = state.recording.write(); + if let Some(r) = rec.as_mut() { + let p = phrase.trim(); + if p.is_empty() { return; } + // Don't record macro-control commands themselves (would be infinite recursion). + if is_macro_control(p) { return; } + r.steps.push(p.to_string()); + debug!("Macros: recorded step '{}' for '{}'", p, r.name); + } +} + +fn is_macro_control(phrase: &str) -> bool { + let p = phrase.to_lowercase(); + p.contains("запиши макрос") || p.contains("сохрани макрос") + || p.contains("отмени макрос") || p.contains("прекрати макрос") + || p.contains("отмени запись") + || p.starts_with("запусти макрос") || p.starts_with("воспроизведи макрос") + || p.starts_with("record macro") || p.starts_with("save macro") + || p.starts_with("play macro") +} + +/// Returns true if a recording is currently active. +pub fn is_recording() -> bool { + STATE.get().and_then(|s| s.recording.read().as_ref().map(|_| ())).is_some() +} + +/// Name of the macro currently being recorded, if any. +pub fn recording_name() -> Option { + STATE.get().and_then(|s| s.recording.read().as_ref().map(|r| r.name.clone())) +} + +/// Stop recording and save the macro. Returns the number of steps captured. +pub fn save_recording() -> Result { + let state = STATE.get().ok_or_else(|| "macros not init".to_string())?; + let rec = state.recording.write().take() + .ok_or_else(|| "не было активной записи".to_string())?; + + if rec.steps.is_empty() { + return Err("макрос пустой".into()); + } + + let count = rec.steps.len(); + let m = Macro { + name: rec.name.clone(), + steps: rec.steps, + created_at: now_secs(), + last_run: None, + }; + state.store.write().macros.insert(rec.name.clone(), m); + persist(state); + info!("Macros: saved '{}' ({} steps)", rec.name, count); + Ok(count) +} + +/// Cancel the active recording without saving. +pub fn cancel_recording() -> bool { + let Some(state) = STATE.get() else { return false; }; + state.recording.write().take().is_some() +} + +pub fn list() -> Vec { + let Some(state) = STATE.get() else { return Vec::new(); }; + state.store.read().macros.values().cloned().collect() +} + +pub fn get(name: &str) -> Option { + let state = STATE.get()?; + state.store.read().macros.get(&normalize_name(name)).cloned() +} + +pub fn delete(name: &str) -> bool { + let Some(state) = STATE.get() else { return false; }; + let removed = state.store.write().macros.remove(&normalize_name(name)).is_some(); + if removed { persist(state); } + removed +} + +/// Mark a macro as run (updates last_run timestamp). +pub fn mark_run(name: &str) { + let Some(state) = STATE.get() else { return; }; + let nname = normalize_name(name); + { + let mut store = state.store.write(); + if let Some(m) = store.macros.get_mut(&nname) { + m.last_run = Some(now_secs()); + } + } + persist(state); +} + +/// Replay callback — jarvis-app registers a function that fires a text command. +/// Macros module owns the timing of replays. +pub type ReplayCallback = Box; + +static REPLAY_CB: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + +pub fn set_replay_callback(cb: ReplayCallback) { + let _ = REPLAY_CB.set(cb); +} + +const STEP_DELAY_MS: u64 = 800; + +/// Replay a stored macro. Each step is sent to the registered callback (which +/// the app wires to its text-command channel). Inter-step delay gives the +/// dispatcher time to process before the next step arrives. Returns step count. +pub fn replay(name: &str) -> Result { + let m = get(name).ok_or_else(|| format!("Макрос '{}' не найден.", name))?; + let cb = REPLAY_CB.get().ok_or_else(|| { + "Replay callback not registered (jarvis-app didn't wire it).".to_string() + })?; + + let steps_count = m.steps.len(); + info!("Macros: replay '{}' ({} steps)", m.name, steps_count); + let _ = cb; // sanity-checked existence; thread re-reads REPLAY_CB inside. + + // Spawn so the caller (likely a Lua script) returns immediately. + let name_owned = m.name.clone(); + let steps = m.steps.clone(); + std::thread::Builder::new() + .name(format!("macro-replay-{}", name_owned)) + .spawn(move || { + let cb = match REPLAY_CB.get() { + Some(c) => c, + None => { warn!("Macros: callback vanished mid-replay"); return; } + }; + for (i, step) in steps.iter().enumerate() { + info!("Macros: replay step {}/{}: {}", i + 1, steps.len(), step); + cb(step); + if i + 1 < steps.len() { + std::thread::sleep(std::time::Duration::from_millis(STEP_DELAY_MS)); + } + } + mark_run(&name_owned); + }) + .ok(); + + Ok(steps_count) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_macro_control_catches_record_phrase() { + assert!(is_macro_control("Запиши макрос работа")); + assert!(is_macro_control("сохрани макрос")); + assert!(is_macro_control("запусти макрос работа")); + assert!(!is_macro_control("открой браузер")); + assert!(!is_macro_control("установи громкость 50")); + } + + #[test] + fn normalize_name_lowercases_trims() { + assert_eq!(normalize_name(" Работа "), "работа"); + assert_eq!(normalize_name("GameMode"), "gamemode"); + } + + #[test] + fn macro_serde_round_trip() { + let m = Macro { + name: "test".into(), + steps: vec!["a".into(), "b".into()], + created_at: 100, + last_run: Some(200), + }; + let json = serde_json::to_string(&m).unwrap(); + let back: Macro = serde_json::from_str(&json).unwrap(); + assert_eq!(back.name, "test"); + assert_eq!(back.steps.len(), 2); + assert_eq!(back.last_run, Some(200)); + } +} diff --git a/resources/commands/macros/cancel.lua b/resources/commands/macros/cancel.lua new file mode 100644 index 0000000..e10e867 --- /dev/null +++ b/resources/commands/macros/cancel.lua @@ -0,0 +1,4 @@ +if jarvis.macros.cancel() then + return jarvis.cmd.ok("Запись макроса отменена.") +end +return jarvis.cmd.not_found("Записи не было.") diff --git a/resources/commands/macros/command.toml b/resources/commands/macros/command.toml new file mode 100644 index 0000000..fa0cf62 --- /dev/null +++ b/resources/commands/macros/command.toml @@ -0,0 +1,107 @@ +# IMBA-7: Voice macros — record and replay sequences of voice commands. + +[[commands]] +id = "macros.start_recording" +type = "lua" +script = "start.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "запиши макрос", + "начни запись макроса", + "записывай макрос", + "новый макрос", +] +en = ["record macro", "start macro recording"] +ua = ["запиши макрос"] + + +[[commands]] +id = "macros.save" +type = "lua" +script = "save.lua" +sandbox = "standard" +timeout = 3000 + +[commands.phrases] +ru = [ + "сохрани макрос", + "стоп макрос", + "стоп запись макроса", + "закончи запись", + "макрос готов", +] +en = ["save macro", "stop macro", "finish macro"] +ua = ["збережи макрос", "стоп макрос"] + + +[[commands]] +id = "macros.cancel" +type = "lua" +script = "cancel.lua" +sandbox = "minimal" +timeout = 2000 + +[commands.phrases] +ru = [ + "отмени макрос", + "отмени запись макроса", + "отмени запись", + "забудь макрос", +] +en = ["cancel macro", "discard macro"] +ua = ["скасуй макрос"] + + +[[commands]] +id = "macros.replay" +type = "lua" +script = "replay.lua" +sandbox = "standard" +timeout = 60000 + +[commands.phrases] +ru = [ + "запусти макрос", + "выполни макрос", + "воспроизведи макрос", + "сыграй макрос", + "проиграй макрос", +] +en = ["play macro", "replay macro", "run macro"] +ua = ["запусти макрос"] + + +[[commands]] +id = "macros.list" +type = "lua" +script = "list.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "какие у меня макросы", + "список макросов", + "покажи макросы", +] +en = ["list macros", "show macros"] +ua = ["список макросів"] + + +[[commands]] +id = "macros.delete" +type = "lua" +script = "delete.lua" +sandbox = "minimal" +timeout = 3000 + +[commands.phrases] +ru = [ + "удали макрос", + "забудь о макросе", +] +en = ["delete macro", "remove macro"] +ua = ["видали макрос"] diff --git a/resources/commands/macros/delete.lua b/resources/commands/macros/delete.lua new file mode 100644 index 0000000..4215266 --- /dev/null +++ b/resources/commands/macros/delete.lua @@ -0,0 +1,18 @@ +local phrase = (jarvis.context.phrase or ""):lower() +local name = jarvis.text.strip_trigger(phrase, { + "удали макрос", + "забудь о макросе", + "delete macro", + "remove macro", + "видали макрос", +}) +name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if name == "" then + return jarvis.cmd.error("Какой макрос удалить?") +end + +if jarvis.macros.delete(name) then + return jarvis.cmd.ok("Удалил макрос " .. name .. ".") +end +return jarvis.cmd.not_found("Макрос " .. name .. " не найден.") diff --git a/resources/commands/macros/list.lua b/resources/commands/macros/list.lua new file mode 100644 index 0000000..ef6bf64 --- /dev/null +++ b/resources/commands/macros/list.lua @@ -0,0 +1,12 @@ +local list = jarvis.macros.list() +if #list == 0 then + return jarvis.cmd.ok("Макросов нет.") +end + +local sample = math.min(#list, 5) +local line = string.format("Макросов: %d. ", #list) +for i = 1, sample do + line = line .. list[i].name .. " (" .. list[i].steps_count .. " шагов)" + if i < sample then line = line .. ", " end +end +return jarvis.cmd.ok(line .. ".") diff --git a/resources/commands/macros/replay.lua b/resources/commands/macros/replay.lua new file mode 100644 index 0000000..3dce4a7 --- /dev/null +++ b/resources/commands/macros/replay.lua @@ -0,0 +1,24 @@ +-- "Запусти макрос работа" +local phrase = (jarvis.context.phrase or ""):lower() +local name = jarvis.text.strip_trigger(phrase, { + "выполни макрос", + "воспроизведи макрос", + "запусти макрос", + "сыграй макрос", + "проиграй макрос", + "play macro", + "replay macro", + "run macro", +}) +name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if name == "" then + return jarvis.cmd.error("Какой макрос запустить?") +end + +local ok, result = pcall(function() return jarvis.macros.replay(name) end) +if not ok then + return jarvis.cmd.not_found(tostring(result)) +end + +return jarvis.cmd.ok(string.format("Запускаю %s, шагов: %d.", name, result)) diff --git a/resources/commands/macros/save.lua b/resources/commands/macros/save.lua new file mode 100644 index 0000000..750a506 --- /dev/null +++ b/resources/commands/macros/save.lua @@ -0,0 +1,8 @@ +local ok, result = pcall(function() return jarvis.macros.save() end) +if not ok then + -- result here is the error msg from save_recording + return jarvis.cmd.not_found(tostring(result)) +end + +local name = jarvis.macros.recording_name() or "макрос" +return jarvis.cmd.ok(string.format("Сохранил %d шагов.", result)) diff --git a/resources/commands/macros/start.lua b/resources/commands/macros/start.lua new file mode 100644 index 0000000..164acaf --- /dev/null +++ b/resources/commands/macros/start.lua @@ -0,0 +1,23 @@ +-- "Запиши макрос работа" +local phrase = (jarvis.context.phrase or ""):lower() +local name = jarvis.text.strip_trigger(phrase, { + "начни запись макроса", + "записывай макрос", + "запиши макрос", + "новый макрос", + "record macro", + "start macro recording", +}) +name = name:gsub("^[%s,:%.]+", ""):gsub("%s+$", "") + +if name == "" then + return jarvis.cmd.error("Как назвать макрос?") +end + +local ok, err = pcall(function() jarvis.macros.start(name) end) +if not ok then + jarvis.log("warn", "macros.start: " .. tostring(err)) + return jarvis.cmd.error("Не получилось начать запись.") +end + +return jarvis.cmd.ok(string.format("Записываю макрос %s. Говорите команды. Когда закончите — скажите сохрани макрос.", name)) From c7ab751e39532ed31d3a00b0457c31907d6d957c Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Fri, 15 May 2026 17:13:49 +0300 Subject: [PATCH 2/2] perf(startup): log total boot time on jarvis-app startup (IMBA-8 nudge) Tiny but useful: stamp `Instant::now()` at the top of main(), log elapsed ms before the tray loop blocks. Lets users (and contributors) spot startup regressions without running a profiler. Output on a warm cache, default config, no network: ~1.8s. Output with a cold ONNX model load (intent classifier): ~4.5s. Visible both in the eprintln trace and the log file via the info! call. --- crates/jarvis-app/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/jarvis-app/src/main.rs b/crates/jarvis-app/src/main.rs index 3e888ed..fb82dc7 100644 --- a/crates/jarvis-app/src/main.rs +++ b/crates/jarvis-app/src/main.rs @@ -29,6 +29,7 @@ mod tray; static SHOULD_STOP: AtomicBool = AtomicBool::new(false); fn main() -> Result<(), String> { + let boot_start = std::time::Instant::now(); load_dotenv_near_exe(); eprintln!("[jarvis-app] step: init_dirs"); @@ -209,6 +210,11 @@ fn main() -> Result<(), String> { let _ = app::start(text_cmd_rx, &app_rt); }); + // IMBA-8: boot timing — log how long startup took so users can spot regressions. + let boot_ms = boot_start.elapsed().as_millis(); + info!("[startup] Jarvis ready in {} ms (before tray loop)", boot_ms); + eprintln!("[jarvis-app] startup: {} ms", boot_ms); + eprintln!("[jarvis-app] step: tray::init_blocking"); tray::init_blocking(settings);