from __future__ import annotations import json import os import re import time from pathlib import Path from typing import Any from src.pet_profiles import normalize_pet ROOT = Path(__file__).resolve().parents[1] DEFAULT_MEMORY_PATH = ROOT / "data" / "memories" / "toy-room-v2.jsonl" def memory_path() -> Path: configured = os.getenv("TOYBOX_MEMORY_PATH", "").strip() return Path(configured).expanduser() if configured else DEFAULT_MEMORY_PATH def load_memories(pet: str | None = None, limit: int = 10) -> list[dict[str, Any]]: path = memory_path() if not path.exists(): return [] wanted_pet = normalize_pet(pet) if pet else None memories: list[dict[str, Any]] = [] try: lines = path.read_text(encoding="utf-8").splitlines() except OSError: return [] for line in reversed(lines[-240:]): try: item = json.loads(line) except json.JSONDecodeError: continue item_pet = normalize_pet(item.get("pet")) if wanted_pet and item_pet not in {wanted_pet, "room"}: continue memories.append(compact_memory(item)) if len(memories) >= limit: break return list(reversed(memories)) def clean_new_memory(value: Any, payload: dict[str, Any]) -> dict[str, Any] | None: if isinstance(value, str): text = value.strip() if not text: return None concept, meaning = infer_concept(text), text elif isinstance(value, dict): concept = str(value.get("concept") or value.get("title") or "").strip() meaning = str(value.get("meaning") or value.get("description") or "").strip() if not concept and meaning: concept = infer_concept(meaning) else: return None concept = safe_text(concept, 48) meaning = safe_text(meaning, 180) if len(concept) < 2 or len(meaning) < 6: return None pet = normalize_pet(payload.get("pet")) message = safe_text(payload.get("message") or "", 180) return { "pet": pet, "concept": concept, "meaning": meaning, "source": "player-teaching" if message else "agent-reflection", "learnedFrom": message, "at": int(time.time()), } def remember_from_action(action: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any] | None: memory = clean_new_memory(action.get("newMemory"), payload) if not memory: return None path = memory_path() try: path.parent.mkdir(parents=True, exist_ok=True) with path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(memory, ensure_ascii=True, separators=(",", ":")) + "\n") except OSError: return None action["newMemory"] = compact_memory(memory) return action["newMemory"] def compact_memory(item: dict[str, Any]) -> dict[str, Any]: return { "pet": normalize_pet(item.get("pet")), "concept": safe_text(item.get("concept") or "", 48), "meaning": safe_text(item.get("meaning") or "", 180), "source": safe_text(item.get("source") or "", 40), "learnedFrom": safe_text(item.get("learnedFrom") or "", 120), "at": int(item.get("at") or 0), } def infer_concept(text: str) -> str: quoted = re.search(r"['\"]([^'\"]{2,48})['\"]", text) if quoted: return quoted.group(1) called = re.search(r"\bcalled\s+['\"]?([a-zA-Z0-9 _-]{2,48}?)(?:['\"]|[:.,;!?]|$)", text, re.IGNORECASE) if called: return called.group(1) rule = re.search(r"\b(?:remember\s+)?(?:this\s+)?rule\s*:\s*([^.,;!?]{2,64})", text, re.IGNORECASE) if rule: return rule.group(1) remember = re.search(r"\bremember\s+(?:that\s+)?([^.,;!?]{2,64})", text, re.IGNORECASE) if remember: return remember.group(1) never = re.search(r"\bnever\s+([^.,;!?]{2,64})", text, re.IGNORECASE) if never: return "never " + never.group(1) always = re.search(r"\balways\s+([^.,;!?]{2,64})", text, re.IGNORECASE) if always: return "always " + always.group(1) words = re.sub(r"[^a-zA-Z0-9 _-]+", " ", text).strip().split() return " ".join(words[:5]) or "new lesson" def safe_text(value: Any, limit: int) -> str: text = re.sub(r"\s+", " ", str(value or "")).strip() return text[:limit]