New module `wave68_handlers.py` (~270 lines) covering the Rust packs that
were Rust-only after Waves 6-8:
- memory_admin: do_memory_count/list/wipe (with full RU plural decl.)
- cooking: do_cooking + reusable match_recipe(phrase) for the 15-dish
preset table. Schedules a "ready!" reminder via scheduler_store.
- leftoff: do_leftoff stitches memory + scheduled tasks + last reply
into a one-paragraph recap. Offline.
- trivia: do_trivia random one-liner from RU/EN MCU pools.
- routines: do_good_night (sleep profile + cancel one-shots),
do_good_morning (default profile + agenda hint),
do_coffee_break (5-min check-in via scheduler).
memory_store: new clear_all() returning removed count, atomic via _save.
extensions.py: imports + set_speak wiring + 9 proxy do_* fns.
main.py: 9 new dispatch elifs.
commands.yaml: 9 new entries (now 196 total).
tests/test_wave68_handlers.py: 15 unit tests covering each handler.
Test helper `_rewire()` re-imports state modules via the existing
isolated_state fixture then re-binds the wave68_handlers module-level
refs — fixes the gotcha where module-level imports cache the OLD
memory_store / scheduler_store instances from process startup.
Tests: 81 → 96 (+15).
136 lines
3.4 KiB
Python
136 lines
3.4 KiB
Python
"""Long-term memory store — port of `crates/jarvis-core/src/long_term_memory.rs`.
|
||
|
||
Stores user facts in JSON at `<script_dir>/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 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)
|
||
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'
|