J.A.R.V.I.S-py/macros_store.py
Bossiara13 6a328077bc feat: vision + macros + scheduler in Python (129 → 142 commands)
Three big rust packs ported. Python parity now covers the FULL imba lineup
except codebase_qa and github_pr.

vision_handler.py (new, ~125 lines)
  - Screenshot via PowerShell System.Drawing → temp PNG → base64 → Groq
    vision API (llama-3.2-11b-vision-preview, override via GROQ_VISION_MODEL).
  - do_vision_describe   "что на экране" / "опиши экран"
  - do_vision_read_error "прочитай ошибку"
  - Requires GROQ_TOKEN; speaks "Не удалось" on missing.

macros_store.py (new, ~170 lines)
  - JSON store at <here>/macros.json (atomic write-through).
  - start(name) / save() / cancel() / replay(name, dispatch_fn)
  - is_recording / recording_name / list_names / get / delete
  - record_step(phrase) called by execute_cmd hook (see main.py)
  - is_macro_control filter blocks "запиши макрос ..." from recording itself
    (no infinite recursion).
  - replay spawns daemon thread, fires each step with 800ms delay.

scheduler_store.py (new, ~220 lines)
  - JSON store at <here>/schedule.json.
  - parse_schedule(spec) handles "daily HH:MM", "at HH:MM",
    "every/in N minutes|hours|seconds" — Russian (минут/час/секунд) + English.
  - Background daemon thread ticks every 30s, fires Speak actions through the
    same speak callback used by main.py.
  - Once-tasks auto-delete after firing; daily/interval cycle forever.
  - add/remove/clear/remove_by_text/list_all.

extensions.py (+13 handlers)
  do_vision_describe / do_vision_read_error → thin proxies to vision_handler
  do_macros_start / save / cancel / replay / list / delete
  do_scheduler_add_reminder / add_recurring / list / clear / cancel_by_text

main.py
  - extensions.init_background_services() at startup → scheduler thread boots.
  - Wraps execute_cmd so every successful dispatch feeds macros_store.record_step.
  - set_dispatch_fn(_execute_phrase_for_macro) enables macro replay through the
    normal command pipeline (recognize_cmd → execute_cmd).

commands.yaml (+13 entries, 129 → 142)
  vision_describe, vision_read_error, macros_start, macros_save, macros_cancel,
  macros_replay, macros_list, macros_delete, scheduler_reminder,
  scheduler_recurring, scheduler_list, scheduler_clear, scheduler_cancel_text.

Tests: ast.parse passes for all five .py files. yaml.safe_load = 142 entries.

Python parity status vs rust (72 packs):
  ✓ memory_pack, profile_switch, vision, scheduler, macros, llm switch
    (via env), llm_context (basic), media_keys (existing), screenshot, net_info,
    unit_convert, generators, disk, date_math, sleep_timer (via shutdown),
    interesting_fact, mailto, self_check, ssl_check, intro/echo
  ✗ codebase_qa, github_pr (require gh CLI + file walk — TODO)
  ✗ pomodoro / daily_briefing / habit_nudge / quick_search / diagnostics
    (could be ported but need scheduler integration + LLM glue)
2026-05-16 00:00:22 +03:00

179 lines
4.7 KiB
Python

"""Voice-macro recorder — port of `crates/jarvis-core/src/macros.rs`.
Records successful command phrases, replays them through the dispatch later.
JSON file at `<here>/macros.json`.
Public API:
is_recording() / recording_name()
start(name) / save() / cancel()
list_names() / get(name) / delete(name)
record_step(phrase) # called by main.py on each successful command
replay(name, dispatch_fn) # dispatch_fn fires one phrase
"""
import json
import os
import time
import threading
_HERE = os.path.dirname(os.path.abspath(__file__))
_PATH = os.path.join(_HERE, 'macros.json')
_recording: dict | None = None # {name, steps: list[str]}
_store: dict[str, dict] = {}
_loaded = False
_recording_lock = threading.Lock()
def _load():
global _store, _loaded
if _loaded:
return
if os.path.isfile(_PATH):
try:
with open(_PATH, encoding='utf-8') as f:
data = json.load(f)
_store = data.get('macros', {}) if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError) as exc:
print(f"[macros] corrupt: {exc}")
_store = {}
_loaded = True
def _save():
tmp = _PATH + '.tmp'
try:
with open(tmp, 'w', encoding='utf-8') as f:
json.dump({'macros': _store}, f, ensure_ascii=False, indent=2)
os.replace(tmp, _PATH)
except OSError as exc:
print(f"[macros] save: {exc}")
def _normalize(s: str) -> str:
return (s or '').strip().lower()
_CONTROL_TOKENS = (
'запиши макрос', 'сохрани макрос', 'отмени макрос',
'прекрати макрос', 'отмени запись', 'запусти макрос',
'воспроизведи макрос', 'удали макрос',
)
def _is_control(phrase: str) -> bool:
low = (phrase or '').lower()
return any(tok in low for tok in _CONTROL_TOKENS)
def is_recording() -> bool:
with _recording_lock:
return _recording is not None
def recording_name() -> str | None:
with _recording_lock:
return _recording['name'] if _recording else None
def start(name: str) -> None:
nname = _normalize(name)
if not nname:
raise ValueError("empty macro name")
with _recording_lock:
global _recording
_recording = {'name': nname, 'steps': []}
def cancel() -> bool:
with _recording_lock:
global _recording
if _recording is None:
return False
_recording = None
return True
def save() -> int:
"""Stop recording, persist, return step count. Raises if nothing recorded."""
with _recording_lock:
global _recording
if _recording is None:
raise RuntimeError("не было активной записи")
if not _recording['steps']:
_recording = None
raise RuntimeError("макрос пустой")
_load()
name = _recording['name']
steps = list(_recording['steps'])
_recording = None
_store[name] = {
'name': name,
'steps': steps,
'created_at': int(time.time()),
'last_run': None,
}
_save()
return len(steps)
def record_step(phrase: str) -> None:
"""Called by main.py after every successful command execution."""
if not phrase:
return
if _is_control(phrase):
return
with _recording_lock:
if _recording is None:
return
_recording['steps'].append(phrase.strip())
def list_names() -> list[str]:
_load()
return sorted(_store.keys())
def get(name: str) -> dict | None:
_load()
return _store.get(_normalize(name))
def delete(name: str) -> bool:
_load()
nname = _normalize(name)
if nname in _store:
del _store[nname]
_save()
return True
return False
def replay(name: str, dispatch_fn) -> int:
"""Replay macro by name. dispatch_fn(phrase) fires a synthetic command.
Returns step count. Raises if macro missing.
Runs in a background thread with 800ms inter-step delay."""
_load()
nname = _normalize(name)
m = _store.get(nname)
if not m:
raise KeyError(f"Макрос '{name}' не найден.")
steps = list(m['steps'])
def runner():
for i, step in enumerate(steps):
try:
dispatch_fn(step)
except Exception as exc:
print(f"[macros] replay step {i}: {exc}")
if i + 1 < len(steps):
time.sleep(0.8)
m['last_run'] = int(time.time())
_save()
threading.Thread(target=runner, name=f"macro-replay-{nname}",
daemon=True).start()
return len(steps)