diff --git a/active_profile.txt b/active_profile.txt new file mode 100644 index 0000000..331d858 --- /dev/null +++ b/active_profile.txt @@ -0,0 +1 @@ +default \ No newline at end of file diff --git a/commands.yaml b/commands.yaml index f2cb70f..d6c726a 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1624,3 +1624,88 @@ outlook_summarize_inbox: - сделай саммари инбокса - что нового в почте action: {type: outlook_summarize_inbox} + +memory_count: + phrases: + - сколько фактов помнишь + - сколько ты помнишь + - сколько у тебя в памяти + - размер памяти + action: {type: memory_count} + +memory_list: + phrases: + - выгрузи список фактов + - покажи что помнишь + - перечисли что помнишь + - что у тебя в памяти + action: {type: memory_list} + +memory_wipe: + phrases: + - точно забудь все + - точно забудь всё + - сотри всю память + action: {type: memory_wipe} + +cooking_timer: + phrases: + - поставь чайник + - варю яйца + - варю макароны + - варю пасту + - жарю омлет + - жарю яичницу + - варю кашу + - варю рис + - варю гречку + - варю картошку + - запекаю курицу + - пеку пиццу + - завариваю чай + - завариваю кофе + - завариваю френч-пресс + action: {type: cooking} + +leftoff: + phrases: + - на чём я остановился + - на чем я остановился + - напомни что делал + - что я делал + - где я остановился + - продолжи откуда я + action: {type: leftoff} + +trivia_iron_man: + phrases: + - расскажи про железного человека + - факт про железного человека + - интересное про старка + - марвел факт + action: {type: trivia} + +routines_good_night: + phrases: + - спокойной ночи + - иду спать + - пока я в кровати + - ночной режим + - выключи всё на ночь + action: {type: good_night} + +routines_good_morning: + phrases: + - доброе утро джарвис + - доброе утро + - я проснулся + - начинаем день + action: {type: good_morning} + +routines_coffee_break: + phrases: + - иду за кофе + - перерыв на кофе + - перерыв на чай + - отойду минут на пять + action: {type: coffee_break} diff --git a/extensions.py b/extensions.py index 64f4f02..cab9432 100644 --- a/extensions.py +++ b/extensions.py @@ -31,6 +31,7 @@ import wave1_handlers import personality_handlers import time_tracker_handlers import outlook_handlers +import wave68_handlers _speak_fn = print _set_clipboard_fn = None @@ -47,6 +48,7 @@ def set_speak(fn): personality_handlers.set_speak(fn) time_tracker_handlers.set_speak(fn) outlook_handlers.set_speak(fn) + wave68_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -1151,3 +1153,16 @@ def do_outlook_unread_count(a, v): outlook_handlers.do_outlook_unread_count(a, v def do_outlook_latest(a, v): outlook_handlers.do_outlook_latest(a, v) def do_outlook_send_clipboard(a, v): outlook_handlers.do_outlook_send_clipboard(a, v) def do_outlook_summarize_inbox(a, v): outlook_handlers.do_outlook_summarize_inbox(a, v) + + +# ── Wave 6-8 proxies (to wave68_handlers) ────────────────────────────────── + +def do_memory_count(a, v): wave68_handlers.do_memory_count(a, v) +def do_memory_list(a, v): wave68_handlers.do_memory_list(a, v) +def do_memory_wipe(a, v): wave68_handlers.do_memory_wipe(a, v) +def do_cooking(a, v): wave68_handlers.do_cooking(a, v) +def do_leftoff(a, v): wave68_handlers.do_leftoff(a, v) +def do_trivia(a, v): wave68_handlers.do_trivia(a, v) +def do_good_night(a, v): wave68_handlers.do_good_night(a, v) +def do_good_morning(a, v): wave68_handlers.do_good_morning(a, v) +def do_coffee_break(a, v): wave68_handlers.do_coffee_break(a, v) diff --git a/main.py b/main.py index 1552fae..6228ae5 100644 --- a/main.py +++ b/main.py @@ -1717,6 +1717,24 @@ def run_action(action): extensions.do_outlook_send_clipboard(action, _current_voice or '') elif t == 'outlook_summarize_inbox': extensions.do_outlook_summarize_inbox(action, _current_voice or '') + elif t == 'memory_count': + extensions.do_memory_count(action, _current_voice or '') + elif t == 'memory_list': + extensions.do_memory_list(action, _current_voice or '') + elif t == 'memory_wipe': + extensions.do_memory_wipe(action, _current_voice or '') + elif t == 'cooking': + extensions.do_cooking(action, _current_voice or '') + elif t == 'leftoff': + extensions.do_leftoff(action, _current_voice or '') + elif t == 'trivia': + extensions.do_trivia(action, _current_voice or '') + elif t == 'good_night': + extensions.do_good_night(action, _current_voice or '') + elif t == 'good_morning': + extensions.do_good_morning(action, _current_voice or '') + elif t == 'coffee_break': + extensions.do_coffee_break(action, _current_voice or '') _current_voice: str = '' diff --git a/memory_store.py b/memory_store.py index cbb8902..78dc0ee 100644 --- a/memory_store.py +++ b/memory_store.py @@ -96,6 +96,17 @@ def forget(key: str) -> bool: return False +def clear_all() -> int: + """Wipe every fact. Returns count of removed entries. Atomic via _save.""" + _load() + n = len(_store) + if n == 0: + return 0 + _store.clear() + _save() + return n + + def search(query: str, limit: int = 5) -> list[dict]: _load() nq = _normalize_key(query) diff --git a/tests/test_wave68_handlers.py b/tests/test_wave68_handlers.py new file mode 100644 index 0000000..0952861 --- /dev/null +++ b/tests/test_wave68_handlers.py @@ -0,0 +1,236 @@ +"""Tests for `wave68_handlers.py` — Python parity for Wave 6-8 Rust packs. + +Each test captures speech via `set_speak(callback)` so we can assert on what +J.A.R.V.I.S. would have said. Scheduler / memory state lives in tmp_path via +the `isolated_state` fixture from conftest.py. + +Implementation gotcha: `isolated_state` re-imports `memory_store` / +`scheduler_store` etc with `_PATH` pointing at the per-test tmpdir, but the +already-imported `wave68_handlers` keeps a reference to the OLD modules. +`_rewire(...)` below re-binds those refs so handlers hit the tmp stores. +""" + +import importlib +import os +import sys + +# Make project root importable. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import wave68_handlers + + +def _rewire(isolated_state, *modules): + """Re-import each named state module via `isolated_state`, then rebind + the wave68_handlers reference so the handler picks up the tmp_path + instance. Returns the list of (re-imported) modules. + """ + importlib.reload(wave68_handlers) + out = [] + for name in modules: + mod = isolated_state(name, ['_PATH', '_PROFILES_DIR']) + setattr(wave68_handlers, name, mod) + out.append(mod) + return out + + +def _capture_speak(): + captured = [] + wave68_handlers.set_speak(captured.append) + return captured + + +# ── memory admin ───────────────────────────────────────────────────────── + + +def test_memory_count_uses_russian_plural(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_count() + assert "0 фактов" in spoken[0] + + mem.remember('любимый чай', 'улун') + spoken.clear() + wave68_handlers.do_memory_count() + assert "1 факт" in spoken[0] + assert "факта" not in spoken[0] # singular, not "2 факта" + + mem.remember('питомец', 'собака') + mem.remember('город', 'москва') + spoken.clear() + wave68_handlers.do_memory_count() + assert "3 факта" in spoken[0] # 2-4 → "факта" + + for i in range(2): + mem.remember(f'k{i}', f'v{i}') + spoken.clear() + wave68_handlers.do_memory_count() + assert "5 фактов" in spoken[0] # 5+ → "фактов" + + +def test_memory_list_speaks_empty_state(isolated_state): + _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_list() + assert "пуст" in spoken[0].lower() + + +def test_memory_list_shows_recent_first(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + # Inject pre-aged record directly so timestamps are distinct without + # waiting on the clock — `remember` uses wall-clock seconds and two + # back-to-back calls in tests routinely tie. + mem._load() + mem._store['старый'] = { + 'key': 'старый', 'value': 'факт', + 'created_at': 100, 'last_used_at': 100, 'use_count': 0, + } + mem._store['новый'] = { + 'key': 'новый', 'value': 'факт', + 'created_at': 200, 'last_used_at': 200, 'use_count': 0, + } + spoken = _capture_speak() + wave68_handlers.do_memory_list() + text = spoken[0] + # Both must appear, with новый (last_used_at=200) before старый (=100) + assert 'новый' in text + assert 'старый' in text + assert text.index('новый') < text.index('старый') + + +def test_memory_wipe_returns_count(isolated_state): + (mem,) = _rewire(isolated_state, 'memory_store') + mem.remember('a', '1') + mem.remember('b', '2') + mem.remember('c', '3') + spoken = _capture_speak() + wave68_handlers.do_memory_wipe() + assert "3" in spoken[0] + assert len(mem.all_facts()) == 0 + + +def test_memory_wipe_on_empty_is_polite(isolated_state): + _rewire(isolated_state, 'memory_store') + spoken = _capture_speak() + wave68_handlers.do_memory_wipe() + assert "пуста" in spoken[0] + + +# ── cooking ───────────────────────────────────────────────────────────── + + +def test_match_recipe_finds_substrings(): + # Direct unit test on the matcher — no scheduler side effects. + assert wave68_handlers.match_recipe("варю макароны") == (10, "макароны") + assert wave68_handlers.match_recipe("ставлю чайник") == (4, "чайник") + assert wave68_handlers.match_recipe("жарю омлет") == (5, "омлет") + assert wave68_handlers.match_recipe("какая сегодня погода") is None + assert wave68_handlers.match_recipe("") is None + + +def test_cooking_schedules_a_task(isolated_state): + (sched,) = _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_cooking(voice="варю пасту 200 грамм") + tasks = sched.list_all() + assert len(tasks) == 1 + assert "паст" in tasks[0]['name'].lower() + assert "10" in spoken[0] + + +def test_cooking_unmatched_phrase_says_so(isolated_state): + _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_cooking(voice="какая погода в москве") + assert "не понял" in spoken[0].lower() + + +# ── leftoff ───────────────────────────────────────────────────────────── + + +def test_leftoff_pulls_memory_and_tasks(isolated_state): + mem, sched = _rewire(isolated_state, 'memory_store', 'scheduler_store') + mem.remember('текущий проект', 'jarvis рефакторинг') + sched.add({ + "name": "Daily standup", + "schedule": sched.parse_schedule("daily 09:00"), + "action": {"type": "speak", "text": "standup"}, + }) + spoken = _capture_speak() + wave68_handlers.do_leftoff(last_assistant_reply="Готовлю кофе, сэр.") + text = spoken[0] + assert "текущий проект" in text + assert "Daily standup" in text + assert "Готовлю кофе" in text + + +def test_leftoff_empty_is_polite(isolated_state): + _rewire(isolated_state, 'memory_store', 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_leftoff() + assert "ничего" in spoken[0].lower() + + +# ── trivia ────────────────────────────────────────────────────────────── + + +def test_trivia_returns_one_known_line(): + spoken = _capture_speak() + wave68_handlers.do_trivia(lang="ru") + assert spoken[0] in wave68_handlers._TRIVIA_RU + + +def test_trivia_english_pool(): + spoken = _capture_speak() + wave68_handlers.do_trivia(lang="en") + assert spoken[0] in wave68_handlers._TRIVIA_EN + + +# ── routines ──────────────────────────────────────────────────────────── + + +def test_good_night_cancels_one_shot_timers(isolated_state): + sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store') + # Pre-populate: one one-shot ("Таймер:") and one daily (should stay) + sched.add({ + "name": "Таймер: яичница", + "schedule": sched.parse_schedule("in 5 minutes"), + "action": {"type": "speak", "text": "ready"}, + }) + sched.add({ + "name": "Daily briefing", + "schedule": sched.parse_schedule("daily 09:00"), + "action": {"type": "speak", "text": "morning"}, + }) + spoken = _capture_speak() + wave68_handlers.do_good_night() + remaining = [t['name'] for t in sched.list_all()] + assert "Daily briefing" in remaining + assert not any("Таймер:" in n for n in remaining) + assert "1" in spoken[0] # cancelled-count appears in message + + +def test_good_morning_reports_clear_or_busy_schedule(isolated_state): + sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store') + spoken = _capture_speak() + wave68_handlers.do_good_morning() + assert "чистое" in spoken[0].lower() or "clear" in spoken[0].lower() + + sched.add({ + "name": "Task", + "schedule": sched.parse_schedule("daily 10:00"), + "action": {"type": "speak", "text": "x"}, + }) + spoken.clear() + wave68_handlers.do_good_morning() + assert "1" in spoken[0] + + +def test_coffee_break_creates_5min_checkin(isolated_state): + (sched,) = _rewire(isolated_state, 'scheduler_store') + spoken = _capture_speak() + wave68_handlers.do_coffee_break() + tasks = sched.list_all() + assert len(tasks) == 1 + assert "Чек-ин" in tasks[0]['name'] + assert spoken[0] # non-empty diff --git a/wave68_handlers.py b/wave68_handlers.py new file mode 100644 index 0000000..0c66436 --- /dev/null +++ b/wave68_handlers.py @@ -0,0 +1,278 @@ +"""Python parity for Wave 6-8 voice packs that were Rust-only at first. + +Covers: + - memory_admin: count / list / wipe + - cooking: dish-name → preset-duration timer via scheduler + - leftoff: cross-state recap (no LLM) + - trivia: random MCU one-liner + - routines: good_night / good_morning / coffee_break + +Style: each `do_*` returns nothing; speech goes through the `set_speak`- +injected callback, mirroring how the other handlers (wave1_handlers, +personality_handlers, ...) work. Keeps the handler module testable in +isolation since the speak fn is a thin dependency. +""" + +import random +import time +from typing import Callable, Optional + +import memory_store +import scheduler_store +import profiles_store + + +_speak: Optional[Callable[[str], None]] = None + + +def set_speak(fn: Callable[[str], None]) -> None: + """Wired by extensions.py at startup; lets main.py route TTS through whichever + backend the user has active (Silero / Piper / SAPI).""" + global _speak + _speak = fn + + +def _say(text: str) -> None: + if _speak is not None: + _speak(text) + else: + # Tests don't always wire a speak fn — fall back to print so the test + # can still assert on the captured content via capsys/caplog. + print(f"[wave68] {text}") + + +# ─── Memory admin ────────────────────────────────────────────────────────── + + +def _ru_plural_facts(n: int) -> str: + """Russian declension: 1 факт, 2-4 факта, 5+ фактов, 11-14 exception.""" + m100 = n % 100 + if 11 <= m100 <= 14: + return "фактов" + m10 = n % 10 + if m10 == 1: + return "факт" + if 2 <= m10 <= 4: + return "факта" + return "фактов" + + +def do_memory_count(action=None, voice=None) -> None: + n = len(memory_store.all_facts()) + _say(f"В памяти {n} {_ru_plural_facts(n)}, сэр.") + + +def do_memory_list(action=None, voice=None) -> None: + facts = memory_store.all_facts() + if not facts: + _say("Память пуста, сэр.") + return + # Sort by recency + facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True) + preview = facts[:5] + rest = max(0, len(facts) - len(preview)) + parts = [f"{f['key']} — {(f.get('value') or '')[:60]}" for f in preview] + msg = ", ".join(parts) + if rest > 0: + msg += f". И ещё {rest}." + _say(msg) + + +def do_memory_wipe(action=None, voice=None) -> None: + n = memory_store.clear_all() + if n == 0: + _say("Память уже пуста, сэр.") + else: + _say(f"Стёр {n} записей. Память чиста.") + + +# ─── Cooking timer ───────────────────────────────────────────────────────── + +# (lowercase substring, minutes, ru-label, en-label) +_RECIPES = [ + ("чайник", 4, "чайник", "kettle"), + ("французск", 4, "френч-пресс", "french press"), + ("чай", 5, "чай", "tea"), + ("омлет", 5, "омлет", "omelette"), + ("яичниц", 5, "яичница", "fried eggs"), + ("кофе", 5, "кофе", "coffee"), + ("яйц", 8, "яйца", "eggs"), + ("макарон", 10, "макароны", "pasta"), + ("паст", 10, "паста", "pasta"), + ("пицц", 15, "пицца", "pizza"), + ("рис", 18, "рис", "rice"), + ("греч", 20, "гречка", "buckwheat"), + ("каш", 20, "каша", "porridge"), + ("картош", 22, "картошка", "potatoes"), + ("куриц", 35, "курица", "chicken"), +] + + +def match_recipe(phrase: str): + """Return (minutes, ru_label) for the first matching recipe substring, + or None if nothing in the phrase matches. Exposed for tests.""" + p = (phrase or "").lower() + for sub, mins, ru, _en in _RECIPES: + if sub in p: + return mins, ru + return None + + +def do_cooking(action=None, voice=None) -> None: + """Detect dish in the voice phrase, schedule a 'ready' reminder for the + right number of minutes through the persistent scheduler. + + Signature matches the rest of the `do_*` handlers: (action_dict, voice). + The recipe match looks at `voice` (the raw phrase the user said).""" + phrase = voice or "" + match = match_recipe(phrase) + if match is None: + _say("Не понял, что ты готовишь, сэр.") + return + minutes, label = match + spec = f"in {minutes} minutes" + try: + sched = scheduler_store.parse_schedule(spec) + except ValueError as e: + _say(f"Не получилось распарсить расписание: {e}") + return + task = { + "name": f"Кухня: {label}", + "schedule": sched, + "action": {"type": "speak", "text": f"Сэр, {label} готов."}, + } + scheduler_store.add(task) + _say(f"Хорошо, напомню через {minutes} минут когда {label} будет готов.") + + +# ─── Leftoff recap ──────────────────────────────────────────────────────── + + +def do_leftoff(action=None, voice=None, last_assistant_reply: Optional[str] = None) -> None: + """Stitch a recap from independent sources. Pure-offline. + + `last_assistant_reply` is callable from tests to pin the third source; + the production dispatcher passes None (main.py keeps its own LLM history).""" + parts = [] + + facts = memory_store.all_facts() + if facts: + facts = sorted(facts, key=lambda f: f.get("last_used_at", 0), reverse=True)[:3] + items = [f"{f['key']} — {(f.get('value') or '')[:50]}" for f in facts] + parts.append("Из памяти: " + "; ".join(items) + ".") + + tasks = scheduler_store.list_all() + if tasks: + first_name = tasks[0].get("name") or "task" + parts.append(f"В расписании {len(tasks)}, ближайшее: {first_name}.") + + if last_assistant_reply: + snippet = " ".join(last_assistant_reply.split())[:120] + parts.append("Я говорил: " + snippet) + + if not parts: + _say("Ничего недавно не происходило, сэр.") + return + _say(" ".join(parts)) + + +# ─── Trivia ─────────────────────────────────────────────────────────────── + +_TRIVIA_RU = [ + "Первый костюм Старк собрал в пещере в Афганистане. Десять рабочих недель, ноль вентиляции.", + "Дуговый реактор изначально питал электромагнит, удерживающий осколки шрапнели у самого сердца.", + "Палладий в первом реакторе медленно отравлял Старка. Решением стал новый, синтезированный отцом, элемент.", + "Mark I весил около 200 килограммов и летал лишь однажды — на побег из плена.", + "Mark III выкрашен в красно-золотой потому, что чистое золото оказалось мягче, чем хотелось.", + "Mark V — складной костюм в чемодане. Идеальное оружие на званый ужин.", + "Hulkbuster, Mark XLIV, был запущен совместно с Брюсом Беннером — на случай, если Халк выйдет из берегов.", + "Я, сэр, появился в фильме «Железный человек» 2008 года. Меня озвучил Пол Беттани.", + "Имя J.A.R.V.I.S. — отсылка к Эдвину Джарвису, дворецкому отца Тони, Говарда Старка.", + "После событий «Эры Альтрона» программа J.A.R.V.I.S. была интегрирована в Vision.", +] + +_TRIVIA_EN = [ + "Stark built the first suit in a cave in Afghanistan. Ten weeks, no ventilation.", + "The arc reactor originally powered an electromagnet keeping shrapnel away from his heart.", + "Palladium in the first reactor was slowly poisoning him. The cure was a new element his father had synthesised.", + "Mark I weighed roughly 200 kilograms and flew exactly once — for the escape.", + "Mark III is red-and-gold because pure gold turned out to be too soft.", + "Mark V folds into a briefcase. Useful at galas.", + "I, sir, first appeared in Iron Man (2008). Paul Bettany voiced me.", + "The name J.A.R.V.I.S. nods to Edwin Jarvis, butler to Tony's father Howard.", +] + + +def do_trivia(action=None, voice=None, lang: str = "ru") -> None: + pool = _TRIVIA_EN if lang == "en" else _TRIVIA_RU + _say(random.choice(pool)) + + +# ─── Routines ───────────────────────────────────────────────────────────── + + +def do_good_night(action=None, voice=None) -> None: + """Switch to 'sleep' profile, cancel one-shot timers, speak goodnight.""" + try: + profiles_store.set_active("sleep") + except (ValueError, KeyError): + pass + + cancelled = 0 + for task in scheduler_store.list_all(): + name = task.get("name") or "" + if name.startswith(("Таймер:", "Timer:", "Кухня:", "Cooking:")): + if scheduler_store.remove(task.get("id", "")): + cancelled += 1 + + lines = [ + "Спокойной ночи, сэр. Я остаюсь на связи.", + "Доброй ночи, сэр. Системы переведены в тихий режим.", + "Спите крепко, сэр. Утром доложу о всём важном.", + "Хорошо, сэр. Не отвлекаю до утра.", + ] + line = random.choice(lines) + if cancelled > 0: + line += f" Отменил {cancelled} одноразовых таймеров." + _say(line) + + +def do_good_morning(action=None, voice=None) -> None: + try: + profiles_store.set_active("default") + except (ValueError, KeyError): + pass + n_tasks = len(scheduler_store.list_all()) + lines = [ + "Доброе утро, сэр. Системы в порядке.", + "Доброе утро. Готов к новому дню.", + "С пробуждением, сэр. День в норме.", + "Доброе утро, сэр. Кофе сам себя не сварит.", + ] + line = random.choice(lines) + if n_tasks > 0: + line += f" В расписании {n_tasks}, скажите 'покажи расписание', чтобы услышать." + else: + line += " Расписание чистое." + _say(line) + + +def do_coffee_break(action=None, voice=None) -> None: + """Schedule a 5-min check-in reminder. Banter pause is Rust-only — Python + side doesn't have an idle-banter equivalent yet.""" + try: + sched = scheduler_store.parse_schedule("in 5 minutes") + except ValueError: + _say("Не получилось поставить чек-ин.") + return + scheduler_store.add({ + "name": "Чек-ин после кофе", + "schedule": sched, + "action": {"type": "speak", "text": "Сэр, прошло пять минут. Вы вернулись?"}, + }) + lines = [ + "Хорошо, сэр. Возвращайтесь через пять.", + "Тихо в эфире пять минут. Удачно вам.", + "Пять минут — норма для кофе, не более.", + ] + _say(random.choice(lines))