From 2bef1d5ea799b94f72a80d3e0b409b637314802f Mon Sep 17 00:00:00 2001
From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com>
Date: Sat, 16 May 2026 01:08:17 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20Wave=201=20python=20parity=20=E2=80=94?=
=?UTF-8?q?=2020=20new=20commands=20(154=20=E2=86=92=20174)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mirrors all 10 packs from rust commit c4b2261 to python.
wave1_handlers.py (new, ~480 lines)
Self-contained module with 20 handlers + DPAPI helpers + http/ps wrappers.
Imports memory_store for state-backed packs (vault, clip, habit, rss, weather).
Coverage:
do_magic_8ball — 15 random answers
do_github_list_issues — gh issue list (uses memory[github.repo])
do_github_my_issues — gh issue list --assignee @me
do_weather_tomorrow — Open-Meteo + ipinfo auto-detect
do_weather_week — 7-day forecast min/max range
do_rss_add/read/list — RSS subscriptions + parse
tags
do_ics_create — write .ics, open via os.startfile
do_backup_export — zipfile of long_term_memory.json + schedule + macros
+ profiles/ + active_profile + llm_backend + settings
(searches script_dir + %APPDATA%/Jarvis)
do_clip_save/list/restore — 20-slot rolling clipboard history in memory
do_notif_missed/clear — enumerate memory[notif.*] / purge
do_vault_save/get/list — DPAPI encrypt via PowerShell, clears clipboard
after 30 sec via threading.Thread
do_habit_checkin/streak — date-keyed memory state, consecutive-day counter
extensions.py: +20 proxy functions + wave1_handlers import + set_speak wiring.
main.py: +20 dispatch entries.
commands.yaml: +20 entries (154 → 174).
Tests: ast.parse passes for all .py files. 42 pytest still green.
---
commands.yaml | 160 ++++++++++++
extensions.py | 26 ++
main.py | 40 +++
wave1_handlers.py | 613 ++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 839 insertions(+)
create mode 100644 wave1_handlers.py
diff --git a/commands.yaml b/commands.yaml
index dc99f0a..2daba39 100644
--- a/commands.yaml
+++ b/commands.yaml
@@ -1335,3 +1335,163 @@ daily_quote:
- вдохнови меня
- цитата
action: {type: daily_quote}
+
+magic_8ball:
+ phrases:
+ - ответь да или нет
+ - магический шар
+ - восьмой шар
+ - скажи да или нет
+ - что скажешь
+ - предсказание
+ action: {type: magic_8ball}
+
+gh_list_issues:
+ phrases:
+ - какие issues
+ - какие задачи
+ - открытые issues
+ - список issues
+ - что в issues
+ - тикеты
+ action: {type: gh_list_issues}
+
+gh_my_issues:
+ phrases:
+ - мои issues
+ - что мне назначено
+ action: {type: gh_my_issues}
+
+weather_tomorrow:
+ phrases:
+ - погода завтра
+ - что с погодой завтра
+ - прогноз на завтра
+ action: {type: weather_tomorrow}
+
+weather_week:
+ phrases:
+ - прогноз на неделю
+ - погода на неделю
+ - недельный прогноз
+ action: {type: weather_week}
+
+rss_add:
+ phrases:
+ - добавь rss
+ - подпишись на
+ - запомни ленту
+ - добавь ленту
+ action: {type: rss_add}
+
+rss_read:
+ phrases:
+ - что в ленте
+ - новости из ленты
+ - почитай rss
+ - новости из rss
+ - новые статьи
+ action: {type: rss_read}
+
+rss_list:
+ phrases:
+ - какие у меня rss
+ - список лент
+ - мои подписки
+ action: {type: rss_list}
+
+ics_create:
+ phrases:
+ - добавь встречу
+ - создай событие
+ - создай встречу
+ - новая встреча
+ action: {type: ics_create}
+
+backup_export:
+ phrases:
+ - сделай бекап
+ - экспортируй настройки
+ - сохрани мои настройки
+ - выгрузи настройки
+ action: {type: backup_export}
+
+clip_save:
+ phrases:
+ - запиши буфер
+ - сохрани буфер
+ - запомни буфер
+ - запомни что в буфере
+ action: {type: clip_save}
+
+clip_list:
+ phrases:
+ - что я копировал
+ - история буфера
+ - последние буферы
+ - что было в буфере
+ action: {type: clip_list}
+
+clip_restore:
+ phrases:
+ - верни первый буфер
+ - верни второй буфер
+ - верни третий буфер
+ - восстанови буфер
+ - вставь старый буфер
+ action: {type: clip_restore}
+
+notif_missed:
+ phrases:
+ - что я пропустил
+ - что было пока меня не было
+ - какие уведомления
+ - пропущенные
+ action: {type: notif_missed}
+
+notif_clear:
+ phrases:
+ - очисти уведомления
+ - забудь уведомления
+ - сбрось пропущенное
+ action: {type: notif_clear}
+
+vault_save:
+ phrases:
+ - сохрани пароль от
+ - запомни пароль от
+ - пароль для
+ action: {type: vault_save}
+
+vault_get:
+ phrases:
+ - пароль от
+ - дай пароль
+ - покажи пароль от
+ - достань пароль
+ action: {type: vault_get}
+
+vault_list:
+ phrases:
+ - какие у меня пароли
+ - список паролей
+ - что в хранилище паролей
+ action: {type: vault_list}
+
+habit_checkin:
+ phrases:
+ - отметь привычку
+ - выполнил привычку
+ - я попил воды
+ - я размялся
+ - я отдохнул глазам
+ action: {type: habit_checkin}
+
+habit_streak:
+ phrases:
+ - сколько дней подряд
+ - мои привычки
+ - статистика привычек
+ - сколько дней пью воду
+ - трекинг привычек
+ action: {type: habit_streak}
diff --git a/extensions.py b/extensions.py
index ba0ed9d..da3a8ab 100644
--- a/extensions.py
+++ b/extensions.py
@@ -27,6 +27,7 @@ import scheduler_store
import vision_handler
import dev_handlers
import llm_backend
+import wave1_handlers
_speak_fn = print
_set_clipboard_fn = None
@@ -39,6 +40,7 @@ def set_speak(fn):
scheduler_store.set_speak(fn)
vision_handler.set_speak(fn)
dev_handlers.set_speak(fn)
+ wave1_handlers.set_speak(fn)
def set_clipboard_fn(fn):
@@ -1093,3 +1095,27 @@ def do_github_list_prs(action, voice):
def do_github_summarize_pr(action, voice):
dev_handlers.do_github_summarize_pr(action, voice)
+
+
+# ── Wave 1 proxies (to wave1_handlers) ──────────────────────────────────────
+
+def do_magic_8ball(a, v): wave1_handlers.do_magic_8ball(a, v)
+def do_gh_list_issues(a, v): wave1_handlers.do_github_list_issues(a, v)
+def do_gh_my_issues(a, v): wave1_handlers.do_github_my_issues(a, v)
+def do_weather_tomorrow(a, v): wave1_handlers.do_weather_tomorrow(a, v)
+def do_weather_week(a, v): wave1_handlers.do_weather_week(a, v)
+def do_rss_add(a, v): wave1_handlers.do_rss_add(a, v)
+def do_rss_read(a, v): wave1_handlers.do_rss_read(a, v)
+def do_rss_list(a, v): wave1_handlers.do_rss_list(a, v)
+def do_ics_create(a, v): wave1_handlers.do_ics_create(a, v)
+def do_backup_export(a, v): wave1_handlers.do_backup_export(a, v)
+def do_clip_save(a, v): wave1_handlers.do_clip_save(a, v)
+def do_clip_list(a, v): wave1_handlers.do_clip_list(a, v)
+def do_clip_restore(a, v): wave1_handlers.do_clip_restore(a, v)
+def do_notif_missed(a, v): wave1_handlers.do_notif_missed(a, v)
+def do_notif_clear(a, v): wave1_handlers.do_notif_clear(a, v)
+def do_vault_save(a, v): wave1_handlers.do_vault_save(a, v)
+def do_vault_get(a, v): wave1_handlers.do_vault_get(a, v)
+def do_vault_list(a, v): wave1_handlers.do_vault_list(a, v)
+def do_habit_checkin(a, v): wave1_handlers.do_habit_checkin(a, v)
+def do_habit_streak(a, v): wave1_handlers.do_habit_streak(a, v)
diff --git a/main.py b/main.py
index d15fe41..e03a185 100644
--- a/main.py
+++ b/main.py
@@ -1636,6 +1636,46 @@ def run_action(action):
extensions.do_world_clock(action, _current_voice or '')
elif t == 'daily_quote':
extensions.do_daily_quote(action, _current_voice or '')
+ elif t == 'magic_8ball':
+ extensions.do_magic_8ball(action, _current_voice or '')
+ elif t == 'gh_list_issues':
+ extensions.do_gh_list_issues(action, _current_voice or '')
+ elif t == 'gh_my_issues':
+ extensions.do_gh_my_issues(action, _current_voice or '')
+ elif t == 'weather_tomorrow':
+ extensions.do_weather_tomorrow(action, _current_voice or '')
+ elif t == 'weather_week':
+ extensions.do_weather_week(action, _current_voice or '')
+ elif t == 'rss_add':
+ extensions.do_rss_add(action, _current_voice or '')
+ elif t == 'rss_read':
+ extensions.do_rss_read(action, _current_voice or '')
+ elif t == 'rss_list':
+ extensions.do_rss_list(action, _current_voice or '')
+ elif t == 'ics_create':
+ extensions.do_ics_create(action, _current_voice or '')
+ elif t == 'backup_export':
+ extensions.do_backup_export(action, _current_voice or '')
+ elif t == 'clip_save':
+ extensions.do_clip_save(action, _current_voice or '')
+ elif t == 'clip_list':
+ extensions.do_clip_list(action, _current_voice or '')
+ elif t == 'clip_restore':
+ extensions.do_clip_restore(action, _current_voice or '')
+ elif t == 'notif_missed':
+ extensions.do_notif_missed(action, _current_voice or '')
+ elif t == 'notif_clear':
+ extensions.do_notif_clear(action, _current_voice or '')
+ elif t == 'vault_save':
+ extensions.do_vault_save(action, _current_voice or '')
+ elif t == 'vault_get':
+ extensions.do_vault_get(action, _current_voice or '')
+ elif t == 'vault_list':
+ extensions.do_vault_list(action, _current_voice or '')
+ elif t == 'habit_checkin':
+ extensions.do_habit_checkin(action, _current_voice or '')
+ elif t == 'habit_streak':
+ extensions.do_habit_streak(action, _current_voice or '')
_current_voice: str = ''
diff --git a/wave1_handlers.py b/wave1_handlers.py
new file mode 100644
index 0000000..9c56af2
--- /dev/null
+++ b/wave1_handlers.py
@@ -0,0 +1,613 @@
+"""Wave 1 handler module — port of 10 rust packs from c4b2261.
+
+magic_8ball, github_issues, weather_extended, rss_reader, ics_event,
+backup, clip_history, notif_queue, password_vault, habit_streaks.
+
+Imported by extensions.py + dispatched from main.py.
+"""
+
+import base64
+import datetime as dt
+import json as _json
+import os
+import random
+import re
+import subprocess
+import urllib.parse
+import urllib.request
+import zipfile
+
+import memory_store
+
+_speak_fn = print
+
+
+def set_speak(fn):
+ global _speak_fn
+ _speak_fn = fn
+
+
+def _speak(text):
+ try:
+ _speak_fn(text)
+ except Exception as exc:
+ print(f"[wave1] speak: {exc}")
+
+
+def _ps(cmd, timeout=15):
+ try:
+ res = subprocess.run(
+ ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cmd],
+ capture_output=True, text=True, encoding='utf-8', timeout=timeout,
+ )
+ return res.returncode == 0, (res.stdout or '').strip(), (res.stderr or '')
+ except Exception as exc:
+ return False, '', str(exc)
+
+
+def _gh(*args, timeout=15):
+ try:
+ res = subprocess.run(
+ ['gh', *args],
+ capture_output=True, text=True, encoding='utf-8', timeout=timeout,
+ )
+ return res.returncode == 0, res.stdout, res.stderr
+ except Exception as exc:
+ return False, '', str(exc)
+
+
+def _http_json(url, timeout=10):
+ try:
+ with urllib.request.urlopen(url, timeout=timeout) as r:
+ return _json.loads(r.read().decode())
+ except Exception as exc:
+ print(f"[wave1] http: {exc}")
+ return None
+
+
+# ── magic_8ball ────────────────────────────────────────────────────────────
+
+_8BALL = [
+ "Без сомнения, сэр.", "Однозначно да.", "Можете на это рассчитывать.",
+ "Скорее всего да.", "Перспективы хорошие.", "Знаки указывают на да.",
+ "Пока неясно, попробуйте позже.", "Сосредоточьтесь и спросите снова.",
+ "Не могу предсказать сейчас.", "Лучше не говорить.",
+ "Не рассчитывайте на это.", "Мой ответ — нет.", "Источники говорят нет.",
+ "Перспективы не очень.", "Очень сомнительно.",
+]
+
+
+def do_magic_8ball(action, voice):
+ _speak(random.choice(_8BALL))
+
+
+# ── github_issues ─────────────────────────────────────────────────────────
+
+def do_github_list_issues(action, voice):
+ repo = memory_store.recall("github.repo")
+ if not repo:
+ _speak("Сначала укажите репозиторий: текущий репо owner/repo.")
+ return
+ ok, out, _ = _gh('issue', 'list', '--repo', repo, '--state', 'open',
+ '--json', 'number,title,labels', '--limit', '10')
+ if not ok:
+ _speak("gh CLI не отвечает.")
+ return
+ try:
+ items = _json.loads(out or '[]')
+ except _json.JSONDecodeError:
+ items = []
+ if not items:
+ _speak("Открытых issues нет.")
+ return
+ titles = [i.get('title', '') for i in items[:3] if i.get('title')]
+ line = f"{len(items)} открытых issues. "
+ if titles:
+ line += "Первые: " + ". ".join(titles) + "."
+ _speak(line)
+
+
+def do_github_my_issues(action, voice):
+ ok, out, _ = _gh('issue', 'list', '--assignee', '@me', '--state', 'open',
+ '--json', 'number,title,repository', '--limit', '10')
+ if not ok:
+ _speak("gh CLI не отвечает.")
+ return
+ try:
+ items = _json.loads(out or '[]')
+ except _json.JSONDecodeError:
+ items = []
+ if not items:
+ _speak("Тебе ничего не назначено.")
+ return
+ titles = [i.get('title', '') for i in items[:3] if i.get('title')]
+ line = f"На вас {len(items)} issues. "
+ if titles:
+ line += "Первые: " + ". ".join(titles) + "."
+ _speak(line)
+
+
+# ── weather_extended ──────────────────────────────────────────────────────
+
+def _weather_location():
+ lat = memory_store.recall("weather.lat")
+ lon = memory_store.recall("weather.lon")
+ city = memory_store.recall("weather.city")
+ if lat and lon:
+ return lat, lon, city or "вашем городе"
+ ip = _http_json("https://ipinfo.io/json", timeout=5)
+ if not ip or not ip.get('loc'):
+ return None, None, None
+ parts = ip['loc'].split(',')
+ if len(parts) != 2:
+ return None, None, None
+ lat, lon = parts[0], parts[1]
+ city = ip.get('city', 'ваш город')
+ memory_store.remember("weather.lat", lat)
+ memory_store.remember("weather.lon", lon)
+ memory_store.remember("weather.city", city)
+ return lat, lon, city
+
+
+def _weather_code_ru(code):
+ if code == 0: return "ясно"
+ if code <= 3: return "переменная облачность"
+ if 51 <= code <= 67: return "дождь"
+ if 71 <= code <= 77: return "снег"
+ if code >= 95: return "гроза"
+ return "переменно"
+
+
+def do_weather_tomorrow(action, voice):
+ lat, lon, city = _weather_location()
+ if not lat:
+ _speak("Не получилось узнать местоположение.")
+ return
+ url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
+ f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code"
+ f"&timezone=auto&forecast_days=2")
+ data = _http_json(url, timeout=10)
+ if not data or 'daily' not in data:
+ _speak("Прогноз недоступен.")
+ return
+ d = data['daily']
+ if len(d.get('temperature_2m_max', [])) < 2:
+ _speak("Прогноз неполный.")
+ return
+ tmax = d['temperature_2m_max'][1]
+ tmin = d['temperature_2m_min'][1]
+ rain = d.get('precipitation_sum', [0, 0])[1] or 0
+ code = d.get('weather_code', [0, 0])[1] or 0
+ line = f"В {city} завтра {_weather_code_ru(code)}. От {round(tmin)} до {round(tmax)} градусов."
+ if rain > 5:
+ line += " Возможны осадки."
+ _speak(line)
+
+
+def do_weather_week(action, voice):
+ lat, lon, city = _weather_location()
+ if not lat:
+ _speak("Не получилось узнать местоположение.")
+ return
+ url = (f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
+ f"&daily=temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7")
+ data = _http_json(url, timeout=10)
+ if not data or 'daily' not in data:
+ _speak("Прогноз недоступен.")
+ return
+ maxes = data['daily'].get('temperature_2m_max', [])
+ mins = data['daily'].get('temperature_2m_min', [])
+ if not maxes:
+ _speak("Прогноз неполный.")
+ return
+ wk_max = max(maxes)
+ wk_min = min(mins)
+ _speak(f"Неделя в {city}: от {round(wk_min)} до {round(wk_max)} градусов.")
+
+
+# ── rss_reader ────────────────────────────────────────────────────────────
+
+def do_rss_add(action, voice):
+ body = (voice or '').strip()
+ low = body.lower()
+ for trig in ('добавь rss', 'подпишись на', 'запомни ленту', 'добавь ленту'):
+ if low.startswith(trig):
+ body = body[len(trig):].strip(' ,.:')
+ break
+ m = re.search(r'(https?://\S+)', body)
+ if not m:
+ _speak("Не нашёл URL.")
+ return
+ url = m.group(1)
+ domain = url.split('://', 1)[1].split('/')[0]
+ memory_store.remember(f"rss.{domain}", url)
+ _speak(f"Лента {domain} добавлена.")
+
+
+def do_rss_read(action, voice):
+ feed_url = None
+ feed_name = None
+ for rec in memory_store.all_facts():
+ if rec['key'].startswith('rss.'):
+ feed_url = rec['value']
+ feed_name = rec['key'][4:]
+ break
+ if not feed_url:
+ _speak("Лент не добавлено. Скажите 'добавь rss и URL'.")
+ return
+ try:
+ with urllib.request.urlopen(feed_url, timeout=10) as r:
+ body = r.read().decode('utf-8', errors='replace')
+ except Exception as exc:
+ print(f"[wave1] rss fetch: {exc}")
+ _speak("Не получилось загрузить ленту.")
+ return
+ titles = re.findall(r']*>([^<]+)', body)
+ if len(titles) < 2:
+ _speak("В ленте нет заголовков.")
+ return
+ sample = titles[1:4] # skip channel title
+ _speak(f"Из {feed_name}. " + ". ".join(sample) + ".")
+
+
+def do_rss_list(action, voice):
+ feeds = [r['key'][4:] for r in memory_store.all_facts() if r['key'].startswith('rss.')]
+ if not feeds:
+ _speak("Лент пока нет.")
+ return
+ _speak("Подписан на: " + ", ".join(feeds) + ".")
+
+
+# ── ics_event ─────────────────────────────────────────────────────────────
+
+def do_ics_create(action, voice):
+ body = (voice or '').strip()
+ low = body.lower()
+ for trig in ('добавь встречу', 'создай событие', 'создай встречу',
+ 'запиши встречу', 'новая встреча'):
+ if low.startswith(trig):
+ body = body[len(trig):].strip(' ,.:')
+ break
+ if not body:
+ _speak("Опишите встречу.")
+ return
+
+ # Find time HH:MM or "в HH"
+ m = re.search(r'(\d{1,2})[:\-\s](\d{2})', body)
+ if m:
+ hh, mm = int(m.group(1)), int(m.group(2))
+ else:
+ m = re.search(r'в\s+(\d{1,2})', body)
+ hh = int(m.group(1)) if m else 9
+ mm = 0
+ hh = max(0, min(23, hh))
+ mm = max(0, min(59, mm))
+
+ today = dt.date.today()
+ low2 = body.lower()
+ if 'сегодня' in low2:
+ target = today
+ elif 'послезавтра' in low2:
+ target = today + dt.timedelta(days=2)
+ else:
+ target = today + dt.timedelta(days=1)
+
+ summary = re.sub(r'\d{1,2}[:\-\s]\d{2}', '', body)
+ summary = re.sub(r'в\s+\d{1,2}', '', summary)
+ summary = re.sub(r'(сегодня|послезавтра|завтра)', '', summary, flags=re.IGNORECASE)
+ summary = summary.strip(' ,.:') or "Встреча"
+
+ now = dt.datetime.now()
+ now_stamp = now.strftime('%Y%m%dT%H%M%S')
+ dt_start = f"{target.strftime('%Y%m%d')}T{hh:02d}{mm:02d}00"
+ end_hh = (hh + 1) % 24
+ dt_end = f"{target.strftime('%Y%m%d')}T{end_hh:02d}{mm:02d}00"
+ uid = f"jarvis-{now_stamp}-{random.randint(1000, 9999)}"
+
+ ics = (
+ "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//J.A.R.V.I.S.//EN\r\n"
+ "BEGIN:VEVENT\r\n"
+ f"UID:{uid}\r\nDTSTAMP:{now_stamp}\r\n"
+ f"DTSTART:{dt_start}\r\nDTEND:{dt_end}\r\n"
+ f"SUMMARY:{summary}\r\n"
+ "END:VEVENT\r\nEND:VCALENDAR\r\n"
+ )
+
+ userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public')
+ out_dir = os.path.join(userprofile, 'Documents', 'jarvis-events')
+ os.makedirs(out_dir, exist_ok=True)
+ out_path = os.path.join(out_dir, f"event-{now_stamp}.ics")
+ try:
+ with open(out_path, 'w', encoding='utf-8') as f:
+ f.write(ics)
+ except OSError as exc:
+ print(f"[wave1] ics write: {exc}")
+ _speak("Не получилось создать файл.")
+ return
+
+ # Open via default handler.
+ os.startfile(out_path)
+ _speak(f"Встреча создана: {summary} в {hh:02d}:{mm:02d}.")
+
+
+# ── backup ────────────────────────────────────────────────────────────────
+
+def do_backup_export(action, voice):
+ cfg_dir = memory_store._PATH and os.path.dirname(memory_store._PATH) or \
+ os.path.dirname(os.path.abspath(__file__))
+ appdata = os.environ.get('APPDATA', '')
+ candidates = []
+ # Search in script-relative + APPDATA/Jarvis
+ for base in (cfg_dir, os.path.join(appdata, 'Jarvis') if appdata else None):
+ if not base or not os.path.isdir(base):
+ continue
+ for fname in ('long_term_memory.json', 'schedule.json', 'macros.json',
+ 'active_profile.txt', 'llm_backend.txt', 'settings.json'):
+ p = os.path.join(base, fname)
+ if os.path.isfile(p):
+ candidates.append(p)
+ prof_dir = os.path.join(base, 'profiles')
+ if os.path.isdir(prof_dir):
+ for f in os.listdir(prof_dir):
+ p = os.path.join(prof_dir, f)
+ if os.path.isfile(p):
+ candidates.append(p)
+ if not candidates:
+ _speak("Нечего бекапить.")
+ return
+
+ userprofile = os.environ.get('USERPROFILE', 'C:\\Users\\Public')
+ stamp = dt.datetime.now().strftime('%Y%m%d-%H%M%S')
+ out_zip = os.path.join(userprofile, 'Documents', f'jarvis-backup-{stamp}.zip')
+ try:
+ with zipfile.ZipFile(out_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
+ for src in candidates:
+ zf.write(src, os.path.basename(src))
+ except OSError as exc:
+ print(f"[wave1] backup: {exc}")
+ _speak("Не получилось создать бекап.")
+ return
+ _speak(f"Бекап сохранён, {len(candidates)} файлов.")
+
+
+# ── clip_history ──────────────────────────────────────────────────────────
+
+def _get_clipboard():
+ """Read current clipboard via PowerShell."""
+ ok, out, _ = _ps("Get-Clipboard -Raw", timeout=5)
+ return out if ok else ''
+
+
+def _set_clipboard(text):
+ """Write to clipboard via PowerShell. None/empty → clears."""
+ try:
+ subprocess.run(
+ ['powershell', '-NoProfile', '-Command', 'Set-Clipboard -Value $input'],
+ input=(text or ''), text=True, encoding='utf-8', timeout=10, check=False,
+ )
+ except Exception as exc:
+ print(f"[wave1] clipboard set: {exc}")
+
+
+def do_clip_save(action, voice):
+ content = _get_clipboard()
+ if not content:
+ _speak("Буфер пуст.")
+ return
+ # Shift
+ for i in range(19, 0, -1):
+ prev = memory_store.recall(f"clip.{i - 1}")
+ if prev:
+ memory_store.remember(f"clip.{i}", prev)
+ trimmed = content if len(content) <= 2000 else content[:2000] + "..."
+ memory_store.remember("clip.0", trimmed)
+ preview = trimmed[:40].replace('\n', ' ').replace('\r', ' ')
+ _speak(f"Запомнил: {preview}.")
+
+
+def do_clip_list(action, voice):
+ count = 0
+ previews = []
+ for i in range(20):
+ val = memory_store.recall(f"clip.{i}")
+ if val:
+ count += 1
+ if len(previews) < 3:
+ p = val[:30].replace('\n', ' ').replace('\r', ' ')
+ previews.append(p)
+ if count == 0:
+ _speak("История буфера пуста.")
+ return
+ _speak("В истории буфера: " + " | ".join(previews) + f". Всего {count} записей.")
+
+
+def do_clip_restore(action, voice):
+ low = (voice or '').lower()
+ idx = 0
+ if 'втор' in low: idx = 1
+ elif 'трет' in low: idx = 2
+ elif 'четверт' in low: idx = 3
+ elif 'пят' in low: idx = 4
+ m = re.search(r'(\d+)', low)
+ if m:
+ idx = int(m.group(1)) - 1
+ content = memory_store.recall(f"clip.{idx}")
+ if not content:
+ _speak("В истории нет такой записи.")
+ return
+ _set_clipboard(content)
+ preview = content[:30].replace('\n', ' ')
+ _speak(f"Восстановил: {preview}.")
+
+
+# ── notif_queue ───────────────────────────────────────────────────────────
+
+def do_notif_missed(action, voice):
+ notifs = []
+ for rec in memory_store.all_facts():
+ if rec['key'].startswith('notif.'):
+ notifs.append((rec.get('last_used_at', 0), rec['value']))
+ if not notifs:
+ _speak("Ничего не пропустил.")
+ return
+ notifs.sort(reverse=True)
+ sample = notifs[:5]
+ _speak(f"Пропущено {len(notifs)} уведомлений. " + ". ".join(n[1] for n in sample) + ".")
+
+
+def do_notif_clear(action, voice):
+ count = 0
+ for rec in memory_store.all_facts():
+ if rec['key'].startswith('notif.'):
+ memory_store.forget(rec['key'])
+ count += 1
+ _speak("Уведомлений и так нет." if count == 0 else f"Очистил {count} уведомлений.")
+
+
+# ── password_vault ────────────────────────────────────────────────────────
+
+def _dpapi_encrypt(plaintext):
+ escaped = plaintext.replace("'", "''")
+ ps = (
+ "Add-Type -AssemblyName System.Security;"
+ f"$bytes = [System.Text.Encoding]::UTF8.GetBytes('{escaped}');"
+ "$enc = [System.Security.Cryptography.ProtectedData]::Protect("
+ "$bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);"
+ "Write-Output ([Convert]::ToBase64String($enc))"
+ )
+ ok, out, _ = _ps(ps, timeout=10)
+ return out if ok else None
+
+
+def _dpapi_decrypt(b64):
+ ps = (
+ "Add-Type -AssemblyName System.Security;"
+ f"$bytes = [Convert]::FromBase64String('{b64}');"
+ "try {"
+ " $dec = [System.Security.Cryptography.ProtectedData]::Unprotect("
+ " $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);"
+ " [System.Text.Encoding]::UTF8.GetString($dec) | Set-Clipboard;"
+ " Write-Output 'OK' } catch { Write-Output 'ERR' }"
+ )
+ ok, out, _ = _ps(ps, timeout=10)
+ return ok and out == 'OK'
+
+
+def _vault_strip_trigger(voice):
+ low = (voice or '').lower()
+ name = voice or ''
+ for trig in ('покажи пароль от', 'достань пароль от', 'пароль от',
+ 'сохрани пароль от', 'запомни пароль от', 'дай пароль',
+ 'пароль для', 'password for', 'get password',
+ 'save password for', 'remember password for'):
+ idx = low.find(trig)
+ if idx >= 0:
+ # find same trigger in original-case voice
+ name = name[idx + len(trig):].strip(' ,.:')
+ break
+ return name.strip()
+
+
+def do_vault_save(action, voice):
+ name = _vault_strip_trigger(voice)
+ if not name:
+ _speak("Имя сервиса не указано.")
+ return
+ plaintext = _get_clipboard()
+ if not plaintext:
+ _speak("Скопируйте пароль в буфер сначала.")
+ return
+ b64 = _dpapi_encrypt(plaintext)
+ if not b64:
+ _speak("Не получилось зашифровать.")
+ return
+ memory_store.remember(f"vault.{name}", b64)
+ _set_clipboard("")
+ _speak(f"Сохранил пароль от {name}.")
+
+
+def do_vault_get(action, voice):
+ name = _vault_strip_trigger(voice)
+ if not name:
+ _speak("От чего пароль?")
+ return
+ b64 = memory_store.recall(f"vault.{name}")
+ if not b64:
+ _speak(f"Пароля для {name} нет.")
+ return
+ if not _dpapi_decrypt(b64):
+ _speak("Не получилось расшифровать.")
+ return
+ # Clear clipboard 30 seconds later via background thread.
+ import threading
+ def _clear():
+ import time
+ time.sleep(30)
+ _set_clipboard("")
+ threading.Thread(target=_clear, daemon=True).start()
+ _speak(f"Пароль от {name} в буфере на 30 секунд.")
+
+
+def do_vault_list(action, voice):
+ names = [r['key'][6:] for r in memory_store.all_facts() if r['key'].startswith('vault.')]
+ if not names:
+ _speak("В хранилище паролей пусто.")
+ return
+ _speak(f"Паролей сохранено {len(names)}: " + ", ".join(names) + ".")
+
+
+# ── habit_streaks ─────────────────────────────────────────────────────────
+
+def _habit_from_voice(voice):
+ low = (voice or '').lower()
+ if 'вод' in low: return 'water'
+ if 'размял' in low or 'зарядк' in low: return 'stretch'
+ if 'глаз' in low: return 'eyes'
+ # Strip leading trigger and use first word
+ for trig in ('отметь привычку', 'выполнил привычку', 'выполнил', 'отметь',
+ 'check in habit'):
+ idx = low.find(trig)
+ if idx >= 0:
+ body = (voice or '')[idx + len(trig):].strip(' ,.:').strip()
+ return body or 'general'
+ return 'general'
+
+
+def do_habit_checkin(action, voice):
+ habit = _habit_from_voice(voice)
+ today = dt.date.today().strftime('%Y-%m-%d')
+ memory_store.remember(f"habit_streak.{habit}.{today}", "1")
+ _speak(f"Отметил {habit} за сегодня.")
+
+
+def do_habit_streak(action, voice):
+ by_habit = {}
+ for rec in memory_store.all_facts():
+ m = re.match(r'^habit_streak\.([^.]+)\.(.+)$', rec['key'])
+ if m:
+ habit, date = m.group(1), m.group(2)
+ by_habit.setdefault(habit, set()).add(date)
+ if not by_habit:
+ _speak("Привычек ещё не отмечено. Скажи 'выполнил привычку' после события.")
+ return
+ today = dt.date.today()
+ results = []
+ for habit, dates in by_habit.items():
+ streak = 0
+ for d in range(31):
+ day = (today - dt.timedelta(days=d)).strftime('%Y-%m-%d')
+ if day in dates:
+ streak += 1
+ else:
+ break
+ results.append((habit, streak))
+ results.sort(key=lambda x: -x[1])
+ sample = results[:3]
+ lines = []
+ for habit, streak in sample:
+ if streak == 0:
+ lines.append(f"{habit} прервана")
+ else:
+ unit = 'день' if streak == 1 else ('дня' if streak < 5 else 'дней')
+ lines.append(f"{habit} {streak} {unit}")
+ _speak("Стрики: " + ", ".join(lines) + ".")