J.A.R.V.I.S-py/profiles_store.py
Bossiara13 02c6f2f90d feat: memory_store + profiles_store + 10 new yaml entries (119 → 129)
Mirrors `long_term_memory` and `profiles` modules from rust fork.

memory_store.py (new, ~95 lines)
  - JSON store at <here>/long_term_memory.json (atomic write).
  - remember(key, value) / recall(key) / forget(key) / search(query, limit) /
    all_facts() / build_llm_context(prompt, limit).
  - Uses the same JSON shape as the rust side, so the file is
    interoperable if you ever symlink the two installations to the same dir.

profiles_store.py (new, ~145 lines)
  - Profiles at <here>/profiles/<name>.json. active_profile.txt persists.
  - Seeds 5 defaults on first init: default ★, work 💼, game 🎮, sleep 🌙,
    driving 🚗 — same as rust fork.
  - active() / active_name() / set_active(name) / list_names() /
    allows_command(cmd_id).

extensions.py (+6 handlers, +160 lines)
  - do_memory_remember  — derives key (first 6 words or split on '=')
  - do_memory_recall    — substring search or top-3 if no query
  - do_memory_forget    — exact then substring fallback
  - do_memory_list      — count + first 5 keys
  - do_profile_set      — target via action.target or voice ("работа" → work)
  - do_profile_what     — speaks current profile + description

main.py
  - +6 dispatch entries for the new action types.

commands.yaml (+10 entries, 119 → 129)
  - memory_remember, memory_recall, memory_forget, memory_list
  - profile_work, profile_game, profile_sleep, profile_driving, profile_default
  - profile_what

Tests: ast.parse passes for all .py files. yaml.safe_load gives 129 entries.

Python parity is now meaningful: memory + profiles are usable by voice
without any rust install. Still missing for full parity: scheduler (needs
background thread), macros (needs phrase-capture state), vision (needs
Groq vision model call), codebase_qa, github_pr.
2026-05-15 23:54:33 +03:00

168 lines
5.6 KiB
Python

"""Profile switching — port of `crates/jarvis-core/src/profiles.rs`.
Profiles live in `<script_dir>/profiles/<name>.json`. Active profile name
persists in `<script_dir>/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)