diff --git a/commands.yaml b/commands.yaml index 123055d..c835c3f 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1033,3 +1033,83 @@ interesting_fact: - интересный факт - расскажи факт action: {type: interesting_fact} + +memory_remember: + phrases: + - запомни обо мне + - запомни что + - помни что + - запомни это + - запомни про меня + action: {type: memory_remember} + +memory_recall: + phrases: + - что ты помнишь о + - что ты знаешь обо мне + - что помнишь про + - вспомни + action: {type: memory_recall} + +memory_forget: + phrases: + - забудь про + - забудь что + - забудь обо мне + action: {type: memory_forget} + +memory_list: + phrases: + - что ты помнишь + - что ты знаешь обо мне всё + - покажи память + action: {type: memory_list} + +profile_work: + phrases: + - режим работа + - включи режим работы + - рабочий режим + - переключись в работу + action: {type: profile_set, target: work} + +profile_game: + phrases: + - режим игра + - игровой режим + - включи игровой режим + - переключись в игры + action: {type: profile_set, target: game} + +profile_sleep: + phrases: + - режим сон + - ночной режим + - режим тишины + - иди спать + - выключи всё + action: {type: profile_set, target: sleep} + +profile_driving: + phrases: + - режим за рулём + - режим вождения + - я за рулём + - поехали + action: {type: profile_set, target: driving} + +profile_default: + phrases: + - обычный режим + - режим по умолчанию + - сбрось режим + - вернись в обычный + action: {type: profile_set, target: default} + +profile_what: + phrases: + - какой сейчас режим + - в каком ты режиме + - текущий режим + - какой режим + action: {type: profile_what} diff --git a/extensions.py b/extensions.py index d29fe72..383a125 100644 --- a/extensions.py +++ b/extensions.py @@ -20,6 +20,9 @@ import uuid as uuidlib import re import urllib.request +import memory_store +import profiles_store + _speak_fn = print _set_clipboard_fn = None @@ -445,6 +448,139 @@ def do_self_ping(action, voice): _speak(random.choice(options)) +# ── memory ────────────────────────────────────────────────────────────────── + +_MEMORY_REMEMBER_TRIGGERS = ( + 'запомни обо мне что', 'запомни обо мне', 'запомни что', + 'помни что', 'запомни это', 'запомни про меня', 'запомни', +) + + +def do_memory_remember(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in _MEMORY_REMEMBER_TRIGGERS: + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + + if not body: + _speak("Что именно запомнить?") + return + + if '=' in body: + key, value = body.split('=', 1) + key, value = key.strip(), value.strip() + else: + words = body.split() + key = ' '.join(words[:6]) + value = body + + if not key: + _speak("Не понял ключ.") + return + + memory_store.remember(key, value) + _speak("Запомнил.") + + +def do_memory_recall(action, voice): + body = (voice or '').lower() + query = '' + for trig in ('что ты помнишь о', 'что ты помнишь про', 'что ты знаешь обо мне', + 'что помнишь про', 'вспомни'): + idx = body.find(trig) + if idx >= 0: + query = body[idx + len(trig):].strip(' ,.:') + break + + if not query: + recs = memory_store.all_facts() + if not recs: + _speak("Ничего пока не помню.") + return + line = "Я помню: " + for r in recs[:3]: + line += f"{r['key']} — {r['value']}. " + _speak(line) + return + + hits = memory_store.search(query, 3) + if not hits: + _speak(f"Ничего не нашёл про {query}.") + return + if len(hits) == 1: + _speak(hits[0]['value']) + else: + _speak('. '.join(f"{h['key']}: {h['value']}" for h in hits)) + + +def do_memory_forget(action, voice): + body = (voice or '').lower() + for trig in ('забудь про', 'забудь что', 'забудь обо мне'): + idx = body.find(trig) + if idx >= 0: + key = body[idx + len(trig):].strip(' ,.:') + if memory_store.forget(key): + _speak(f"Забыл про {key}.") + return + hits = memory_store.search(key, 1) + if hits: + memory_store.forget(hits[0]['key']) + _speak(f"Забыл про {hits[0]['key']}.") + return + _speak(f"Я и не помнил про {key}.") + return + _speak("Что забыть?") + + +def do_memory_list(action, voice): + recs = memory_store.all_facts() + if not recs: + _speak("Я пока ничего о вас не запомнил.") + return + sample = min(len(recs), 5) + line = f"Помню {len(recs)} фактов. Первые: " + line += ", ".join(r['key'] for r in recs[:sample]) + "." + _speak(line) + + +# ── profile switch ────────────────────────────────────────────────────────── + +def do_profile_set(action, voice): + target = action.get('target') or _profile_target_from_voice(voice) + if not target: + _speak("Не понял какой режим.") + return + try: + p = profiles_store.set_active(target) + except ValueError: + _speak(f"Профиль {target} не найден.") + return + greeting = p.get('greeting', '') or f"Режим {target}." + _speak(greeting) + + +def _profile_target_from_voice(voice): + low = (voice or '').lower() + if 'работ' in low: return 'work' + if 'игр' in low: return 'game' + if 'сон' in low or 'спать' in low or 'тиш' in low: return 'sleep' + if 'руль' in low or 'вожд' in low or 'поехали' in low: return 'driving' + if 'обычн' in low or 'умолчан' in low or 'сброс' in low or 'верн' in low: return 'default' + return '' + + +def do_profile_what(action, voice): + p = profiles_store.active() + name = p.get('name', 'default') + desc = p.get('description', '') + msg = f"Сейчас {name}." + if desc: + msg += " " + desc + _speak(msg) + + # ── interesting fact (LLM-dependent) ─────────────────────────────────────── def do_interesting_fact(action, voice): diff --git a/main.py b/main.py index 8d72d2f..953863e 100644 --- a/main.py +++ b/main.py @@ -1575,6 +1575,18 @@ def run_action(action): extensions.do_self_ping(action, _current_voice or '') elif t == 'interesting_fact': extensions.do_interesting_fact(action, _current_voice or '') + elif t == 'memory_remember': + extensions.do_memory_remember(action, _current_voice or '') + elif t == 'memory_recall': + extensions.do_memory_recall(action, _current_voice or '') + elif t == 'memory_forget': + extensions.do_memory_forget(action, _current_voice or '') + elif t == 'memory_list': + extensions.do_memory_list(action, _current_voice or '') + elif t == 'profile_set': + extensions.do_profile_set(action, _current_voice or '') + elif t == 'profile_what': + extensions.do_profile_what(action, _current_voice or '') _current_voice: str = '' diff --git a/memory_store.py b/memory_store.py new file mode 100644 index 0000000..cbb8902 --- /dev/null +++ b/memory_store.py @@ -0,0 +1,125 @@ +"""Long-term memory store — port of `crates/jarvis-core/src/long_term_memory.rs`. + +Stores user facts in JSON at `/long_term_memory.json`. +Atomic write-through (write-then-rename). + +Public API: + remember(key, value) -> None + recall(key) -> str | None + forget(key) -> bool + search(query, limit=5) -> list[dict] + all_facts() -> list[dict] + build_llm_context(prompt, limit=5) -> str # used by chat handler +""" + +import json +import os +import time + +# JSON file lives in the same folder as main.py so the user can move the +# install and the data follows. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PATH = os.path.join(_HERE, 'long_term_memory.json') + +_store: dict[str, dict] = {} +_loaded = False + + +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('entries', {}) if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + print(f"[memory] corrupt store ({exc}) — starting empty") + _store = {} + _loaded = True + + +def _save(): + tmp = _PATH + '.tmp' + try: + with open(tmp, 'w', encoding='utf-8') as f: + json.dump({'entries': _store}, f, ensure_ascii=False, indent=2) + os.replace(tmp, _PATH) + except OSError as exc: + print(f"[memory] save failed: {exc}") + + +def _normalize_key(k: str) -> str: + return (k or '').strip().lower() + + +def remember(key: str, value: str) -> None: + _load() + nk = _normalize_key(key) + if not nk: + return + now = int(time.time()) + if nk in _store: + _store[nk]['value'] = value + _store[nk]['last_used_at'] = now + else: + _store[nk] = { + 'key': nk, + 'value': value, + 'created_at': now, + 'last_used_at': now, + 'use_count': 0, + } + _save() + + +def recall(key: str) -> str | None: + _load() + nk = _normalize_key(key) + entry = _store.get(nk) + if not entry: + return None + entry['last_used_at'] = int(time.time()) + entry['use_count'] = entry.get('use_count', 0) + 1 + _save() + return entry['value'] + + +def forget(key: str) -> bool: + _load() + nk = _normalize_key(key) + if nk in _store: + del _store[nk] + _save() + return True + return False + + +def search(query: str, limit: int = 5) -> list[dict]: + _load() + nq = _normalize_key(query) + if not nq: + return [] + hits = [ + e for e in _store.values() + if nq in e['key'] or nq in e['value'].lower() + ] + hits.sort(key=lambda e: e.get('last_used_at', 0), reverse=True) + return hits[: max(1, limit)] + + +def all_facts() -> list[dict]: + _load() + return list(_store.values()) + + +def build_llm_context(prompt: str, limit: int = 5) -> str: + """Format relevant memory facts as a system-prompt addendum.""" + hits = search(prompt, limit) + if not hits: + return '' + lines = ["Известные факты о пользователе (используй если уместно):"] + for h in hits: + lines.append(f"- {h['key']} = {h['value']}") + return '\n'.join(lines) + '\n' diff --git a/profiles_store.py b/profiles_store.py new file mode 100644 index 0000000..a7123f3 --- /dev/null +++ b/profiles_store.py @@ -0,0 +1,168 @@ +"""Profile switching — port of `crates/jarvis-core/src/profiles.rs`. + +Profiles live in `/profiles/.json`. Active profile name +persists in `/active_profile.txt`. Seeds 5 defaults on first run. + +Public API: + active() -> dict # currently selected profile + set_active(name) -> dict # raises ValueError if profile missing + list_names() -> list[str] + allows_command(cmd_id) -> bool +""" + +import json +import os + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROFILES_DIR = os.path.join(_HERE, 'profiles') +_ACTIVE_FILE = os.path.join(_HERE, 'active_profile.txt') + +_active_cache: dict | None = None + + +def _seed_defaults(): + defaults = [ + { + 'name': 'default', + 'description': 'Обычный режим, все команды доступны.', + 'llm_personality': '', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': [], + 'greeting': '', + 'icon': '★', + }, + { + 'name': 'work', + 'description': 'Рабочий режим — фокус, отключены развлечения.', + 'llm_personality': 'Отвечай кратко и по делу. Минимум шуток.', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': ['games.', 'fun.', 'music.'], + 'greeting': 'Режим работы. Сосредоточимся.', + 'icon': '💼', + }, + { + 'name': 'game', + 'description': 'Игровой режим — голосовые макросы и игры.', + 'llm_personality': 'Будь дружелюбен. Краткие ответы. Игровая атмосфера.', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': ['reminders.', 'calendar.'], + 'greeting': 'Игровой режим активирован.', + 'icon': '🎮', + }, + { + 'name': 'sleep', + 'description': 'Спокойный режим — тихий голос, никаких уведомлений.', + 'llm_personality': 'Говори спокойно, короткими фразами.', + 'allowed_command_prefixes': ['time.', 'weather.', 'lights.'], + 'disabled_command_prefixes': [], + 'greeting': 'Спокойной ночи.', + 'icon': '🌙', + }, + { + 'name': 'driving', + 'description': 'Режим за рулём — только безопасные команды.', + 'llm_personality': 'Краткие безопасные ответы. Никаких длинных текстов.', + 'allowed_command_prefixes': [ + 'music.', 'navigation.', 'calls.', + 'time.', 'weather.', 'reminders.', + ], + 'disabled_command_prefixes': ['windows.', 'screen.'], + 'greeting': 'Режим за рулём. Веди безопасно.', + 'icon': '🚗', + }, + ] + os.makedirs(_PROFILES_DIR, exist_ok=True) + for p in defaults: + path = os.path.join(_PROFILES_DIR, f"{p['name']}.json") + if not os.path.isfile(path): + try: + with open(path, 'w', encoding='utf-8') as f: + json.dump(p, f, ensure_ascii=False, indent=2) + except OSError as exc: + print(f"[profiles] seed {p['name']} failed: {exc}") + + +def _default_profile() -> dict: + return { + 'name': 'default', + 'description': 'fallback', + 'llm_personality': '', + 'allowed_command_prefixes': [], + 'disabled_command_prefixes': [], + 'greeting': '', + 'icon': '★', + } + + +def _load_profile(name: str) -> dict | None: + path = os.path.join(_PROFILES_DIR, f'{name}.json') + if not os.path.isfile(path): + return None + try: + with open(path, encoding='utf-8') as f: + return json.load(f) + except (OSError, json.JSONDecodeError) as exc: + print(f"[profiles] load {name} failed: {exc}") + return None + + +def _read_active_name() -> str: + try: + with open(_ACTIVE_FILE, encoding='utf-8') as f: + return f.read().strip() or 'default' + except OSError: + return 'default' + + +def _init(): + global _active_cache + if _active_cache is not None: + return + _seed_defaults() + name = _read_active_name() + _active_cache = _load_profile(name) or _default_profile() + + +def active() -> dict: + _init() + return dict(_active_cache or _default_profile()) + + +def active_name() -> str: + return active().get('name', 'default') + + +def set_active(name: str) -> dict: + global _active_cache + _init() + profile = _load_profile(name) + if not profile: + raise ValueError(f"profile '{name}' not found") + _active_cache = profile + try: + with open(_ACTIVE_FILE, 'w', encoding='utf-8') as f: + f.write(name) + except OSError as exc: + print(f"[profiles] persist failed: {exc}") + return dict(profile) + + +def list_names() -> list[str]: + _init() + names = [] + if os.path.isdir(_PROFILES_DIR): + for fname in os.listdir(_PROFILES_DIR): + if fname.endswith('.json'): + names.append(fname[:-5]) + return sorted(names) + + +def allows_command(cmd_id: str) -> bool: + p = active() + disabled = p.get('disabled_command_prefixes', []) or [] + if any(cmd_id.startswith(pfx) for pfx in disabled): + return False + allowed = p.get('allowed_command_prefixes', []) or [] + if not allowed: + return True + return any(cmd_id.startswith(pfx) for pfx in allowed)