diff --git a/.gitignore b/.gitignore index 065a20f..8811858 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,6 @@ schedule.json macros.json llm_backend.txt llm_history.json -active_profile.txt -time_tracker.json # C extensions *.so diff --git a/commands.yaml b/commands.yaml index d6c726a..f2cb70f 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1624,88 +1624,3 @@ 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 cab9432..64f4f02 100644 --- a/extensions.py +++ b/extensions.py @@ -31,7 +31,6 @@ import wave1_handlers import personality_handlers import time_tracker_handlers import outlook_handlers -import wave68_handlers _speak_fn = print _set_clipboard_fn = None @@ -48,7 +47,6 @@ 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): @@ -1153,16 +1151,3 @@ 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 6228ae5..1552fae 100644 --- a/main.py +++ b/main.py @@ -1717,24 +1717,6 @@ 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 78dc0ee..cbb8902 100644 --- a/memory_store.py +++ b/memory_store.py @@ -96,17 +96,6 @@ 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 deleted file mode 100644 index 0952861..0000000 --- a/tests/test_wave68_handlers.py +++ /dev/null @@ -1,236 +0,0 @@ -"""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 deleted file mode 100644 index 0c66436..0000000 --- a/wave68_handlers.py +++ /dev/null @@ -1,278 +0,0 @@ -"""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))