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)
This commit is contained in:
parent
02c6f2f90d
commit
6a328077bc
6 changed files with 1015 additions and 0 deletions
242
extensions.py
242
extensions.py
|
|
@ -22,6 +22,9 @@ import urllib.request
|
|||
|
||||
import memory_store
|
||||
import profiles_store
|
||||
import macros_store
|
||||
import scheduler_store
|
||||
import vision_handler
|
||||
|
||||
_speak_fn = print
|
||||
_set_clipboard_fn = None
|
||||
|
|
@ -31,6 +34,8 @@ def set_speak(fn):
|
|||
"""Register the main.py _speak function as our voice output."""
|
||||
global _speak_fn
|
||||
_speak_fn = fn
|
||||
scheduler_store.set_speak(fn)
|
||||
vision_handler.set_speak(fn)
|
||||
|
||||
|
||||
def set_clipboard_fn(fn):
|
||||
|
|
@ -39,6 +44,11 @@ def set_clipboard_fn(fn):
|
|||
_set_clipboard_fn = fn
|
||||
|
||||
|
||||
def init_background_services():
|
||||
"""Boot scheduler. Idempotent. Called once from main.py at startup."""
|
||||
scheduler_store.init()
|
||||
|
||||
|
||||
def _speak(text):
|
||||
try:
|
||||
_speak_fn(text)
|
||||
|
|
@ -581,6 +591,238 @@ def do_profile_what(action, voice):
|
|||
_speak(msg)
|
||||
|
||||
|
||||
# ── vision ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def do_vision_describe(action, voice):
|
||||
vision_handler.do_vision_describe(action, voice)
|
||||
|
||||
|
||||
def do_vision_read_error(action, voice):
|
||||
vision_handler.do_vision_read_error(action, voice)
|
||||
|
||||
|
||||
# ── macros ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _strip_trig_lower(voice: str, triggers):
|
||||
low = (voice or '').lower()
|
||||
for t in triggers:
|
||||
idx = low.find(t)
|
||||
if idx >= 0:
|
||||
return (voice or '')[idx + len(t):].strip(' ,.:')
|
||||
return voice or ''
|
||||
|
||||
|
||||
def do_macros_start(action, voice):
|
||||
name = _strip_trig_lower(voice, (
|
||||
'начни запись макроса', 'записывай макрос',
|
||||
'запиши макрос', 'новый макрос',
|
||||
)).strip()
|
||||
if not name:
|
||||
_speak("Как назвать макрос?")
|
||||
return
|
||||
try:
|
||||
macros_store.start(name)
|
||||
except ValueError as exc:
|
||||
_speak(f"Не получилось: {exc}.")
|
||||
return
|
||||
_speak(f"Записываю макрос {name}. Говорите команды. Когда закончите — скажите сохрани макрос.")
|
||||
|
||||
|
||||
def do_macros_save(action, voice):
|
||||
try:
|
||||
count = macros_store.save()
|
||||
except (RuntimeError,) as exc:
|
||||
_speak(str(exc))
|
||||
return
|
||||
_speak(f"Сохранил {count} шагов.")
|
||||
|
||||
|
||||
def do_macros_cancel(action, voice):
|
||||
if macros_store.cancel():
|
||||
_speak("Запись макроса отменена.")
|
||||
else:
|
||||
_speak("Записи не было.")
|
||||
|
||||
|
||||
_DISPATCH_FN = None
|
||||
|
||||
|
||||
def set_dispatch_fn(fn):
|
||||
"""Register the main.py `execute_cmd(matched_phrase, voice)` for replay."""
|
||||
global _DISPATCH_FN
|
||||
_DISPATCH_FN = fn
|
||||
|
||||
|
||||
def do_macros_replay(action, voice):
|
||||
name = _strip_trig_lower(voice, (
|
||||
'выполни макрос', 'воспроизведи макрос',
|
||||
'запусти макрос', 'сыграй макрос', 'проиграй макрос',
|
||||
)).strip()
|
||||
if not name:
|
||||
_speak("Какой макрос запустить?")
|
||||
return
|
||||
if _DISPATCH_FN is None:
|
||||
_speak("Replay не подключен.")
|
||||
return
|
||||
try:
|
||||
count = macros_store.replay(name, _DISPATCH_FN)
|
||||
except KeyError as exc:
|
||||
_speak(str(exc))
|
||||
return
|
||||
_speak(f"Запускаю {name}, шагов: {count}.")
|
||||
|
||||
|
||||
def do_macros_list(action, voice):
|
||||
names = macros_store.list_names()
|
||||
if not names:
|
||||
_speak("Макросов нет.")
|
||||
return
|
||||
sample = names[:5]
|
||||
line = f"Макросов: {len(names)}. Первые: " + ", ".join(sample) + "."
|
||||
_speak(line)
|
||||
|
||||
|
||||
def do_macros_delete(action, voice):
|
||||
name = _strip_trig_lower(voice, (
|
||||
'удали макрос', 'забудь о макросе',
|
||||
)).strip()
|
||||
if not name:
|
||||
_speak("Какой макрос удалить?")
|
||||
return
|
||||
if macros_store.delete(name):
|
||||
_speak(f"Удалил макрос {name}.")
|
||||
else:
|
||||
_speak(f"Макрос {name} не найден.")
|
||||
|
||||
|
||||
# ── scheduler ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_duration_phrase(voice: str) -> tuple[int, str] | None:
|
||||
"""From 'через 5 минут' / 'через 2 часа', return (count, unit_word)."""
|
||||
low = (voice or '').lower()
|
||||
m = re.search(r'(\d+)\s+(\S+)', low)
|
||||
if not m:
|
||||
return None
|
||||
return (int(m.group(1)), m.group(2))
|
||||
|
||||
|
||||
def do_scheduler_add_reminder(action, voice):
|
||||
body = _strip_trig_lower(voice, (
|
||||
'напомни мне через', 'напомни через',
|
||||
'поставь напоминалку через', 'поставь напоминание через',
|
||||
'разбуди через',
|
||||
))
|
||||
body = body.strip(' ,.:')
|
||||
parsed = _parse_duration_phrase(body)
|
||||
if not parsed:
|
||||
_speak("Не понял через сколько.")
|
||||
return
|
||||
n, unit = parsed
|
||||
if unit.startswith('минут'):
|
||||
spec = f"in {n} minutes"
|
||||
label = f"{n} минут"
|
||||
elif unit.startswith('час'):
|
||||
spec = f"in {n} hours"
|
||||
label = f"{n} час" if n == 1 else (f"{n} часа" if n < 5 else f"{n} часов")
|
||||
elif unit.startswith('секунд'):
|
||||
spec = f"in {n} seconds"
|
||||
label = f"{n} секунд"
|
||||
else:
|
||||
_speak("Не понял единицу. Минуты или часы.")
|
||||
return
|
||||
|
||||
# Body after the number+unit is the reminder text.
|
||||
text = re.sub(r'^\d+\s+\S+\s*', '', body).strip() or "Напоминание."
|
||||
try:
|
||||
scheduler_store.add({
|
||||
'name': 'Reminder',
|
||||
'schedule': spec,
|
||||
'action': {'type': 'speak', 'text': text},
|
||||
})
|
||||
except ValueError as exc:
|
||||
_speak(f"Не получилось: {exc}.")
|
||||
return
|
||||
_speak(f"Напомню через {label}.")
|
||||
|
||||
|
||||
def do_scheduler_add_recurring(action, voice):
|
||||
body = _strip_trig_lower(voice, (
|
||||
'напоминай каждые', 'напоминай каждый',
|
||||
'каждые', 'каждый час',
|
||||
))
|
||||
body = body.strip(' ,.:')
|
||||
parsed = _parse_duration_phrase(body)
|
||||
if not parsed:
|
||||
# try implicit 1
|
||||
m = re.match(r'^(\S+)', body)
|
||||
if m and m.group(1) in ('час', 'минуту', 'минута'):
|
||||
n = 1
|
||||
unit = m.group(1)
|
||||
else:
|
||||
_speak("Не понял через сколько повторять.")
|
||||
return
|
||||
else:
|
||||
n, unit = parsed
|
||||
|
||||
if unit.startswith('минут'):
|
||||
spec = f"every {n} minutes"
|
||||
sample = f"{n} минут"
|
||||
elif unit.startswith('час'):
|
||||
spec = f"every {n} hours"
|
||||
sample = f"{n} час" if n == 1 else (f"{n} часа" if n < 5 else f"{n} часов")
|
||||
else:
|
||||
_speak("Не понял единицу.")
|
||||
return
|
||||
|
||||
text = re.sub(r'^\d+\s+\S+\s*', '', body).strip() or "Напоминание."
|
||||
text = re.sub(r'^напоминай\s+', '', text).strip() or "Напоминание."
|
||||
try:
|
||||
scheduler_store.add({
|
||||
'name': 'Recurring',
|
||||
'schedule': spec,
|
||||
'action': {'type': 'speak', 'text': text},
|
||||
})
|
||||
except ValueError as exc:
|
||||
_speak(f"Не получилось: {exc}.")
|
||||
return
|
||||
_speak(f"Буду напоминать каждые {sample}.")
|
||||
|
||||
|
||||
def do_scheduler_list(action, voice):
|
||||
tasks = scheduler_store.list_all()
|
||||
if not tasks:
|
||||
_speak("Расписание пустое.")
|
||||
return
|
||||
line = f"Запланировано {len(tasks)}."
|
||||
_speak(line)
|
||||
|
||||
|
||||
def do_scheduler_clear(action, voice):
|
||||
n = scheduler_store.clear()
|
||||
if n == 0:
|
||||
_speak("В расписании и так пусто.")
|
||||
else:
|
||||
_speak(f"Удалил {n} задач.")
|
||||
|
||||
|
||||
def do_scheduler_cancel_by_text(action, voice):
|
||||
query = _strip_trig_lower(voice, (
|
||||
'отмени напоминание про', 'отмени напоминание о',
|
||||
'удали задачу про', 'удали задачу о',
|
||||
'отмени про', 'забудь напоминание про',
|
||||
)).strip()
|
||||
if not query:
|
||||
_speak("Что именно отменить?")
|
||||
return
|
||||
n = scheduler_store.remove_by_text(query)
|
||||
if n == 0:
|
||||
_speak(f"Не нашёл напоминаний про {query}.")
|
||||
elif n == 1:
|
||||
_speak("Отменил напоминание.")
|
||||
else:
|
||||
_speak(f"Отменил {n} напоминаний про {query}.")
|
||||
|
||||
|
||||
# ── interesting fact (LLM-dependent) ───────────────────────────────────────
|
||||
|
||||
def do_interesting_fact(action, voice):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue