feat(macros): IMBA-7 voice-recorded command sequences (VoiceAttack-style)

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
    <APP_CONFIG_DIR>/macros.json with atomic write-through.
  - Recording state in-memory: RwLock<Option<RecordingState>>.
  - 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.
This commit is contained in:
Bossiara13 2026-05-15 17:12:32 +03:00
parent d63399f4c9
commit dbb4a6b888
14 changed files with 594 additions and 2 deletions

View file

@ -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,

View file

@ -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::<String>();
// 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 => {

View file

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

View file

@ -13,4 +13,5 @@ pub mod profile;
pub mod vision;
pub mod scheduler;
pub mod cmd;
pub mod health;
pub mod health;
pub mod macros;

View file

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

View file

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

View file

@ -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 `<APP_CONFIG_DIR>/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<String>,
#[serde(default)]
pub created_at: i64,
#[serde(default)]
pub last_run: Option<i64>,
}
#[derive(Default, Debug, Serialize, Deserialize)]
struct Store {
#[serde(default)]
macros: BTreeMap<String, Macro>,
}
#[derive(Debug)]
struct RecordingState {
name: String,
steps: Vec<String>,
}
struct State {
path: PathBuf,
store: RwLock<Store>,
recording: RwLock<Option<RecordingState>>,
}
static STATE: OnceCell<State> = 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<String> {
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<usize, String> {
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<Macro> {
let Some(state) = STATE.get() else { return Vec::new(); };
state.store.read().macros.values().cloned().collect()
}
pub fn get(name: &str) -> Option<Macro> {
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<dyn Fn(&str) + Send + Sync>;
static REPLAY_CB: once_cell::sync::OnceCell<ReplayCallback> = 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<usize, String> {
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));
}
}

View file

@ -0,0 +1,4 @@
if jarvis.macros.cancel() then
return jarvis.cmd.ok("Запись макроса отменена.")
end
return jarvis.cmd.not_found("Записи не было.")

View file

@ -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 = ["видали макрос"]

View file

@ -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 .. " не найден.")

View file

@ -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 .. ".")

View file

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

View file

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

View file

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