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)
179 lines
4.7 KiB
Python
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)
|