""" Shared player memory system for Mortdecai. Per-server JSON storage with owner tagging. Players can save locations, preferences, and facts. Cross-player references supported. Usage: from agent.tools.player_memory import write_memory, read_memory, format_memory_context write_memory(config, "slingshooter08", "slingshooter08", "location", "home", {"x": 100, "y": 64, "z": 200}) memories = read_memory(config, owner="slingshooter08", key="home") context = format_memory_context(config, "slingshooter08") """ import json import os import threading import time from typing import Any, Dict, List, Optional _lock = threading.Lock() DEFAULT_MEMORY_PATH = "/opt/paper-ai-25567/player_memory.json" MAX_PER_PLAYER = 50 MAX_TOTAL = 500 def _memory_path(config: dict) -> str: return config.get("player_memory_path", DEFAULT_MEMORY_PATH) def load_memory(config: dict) -> dict: path = _memory_path(config) with _lock: try: with open(path) as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {"memories": []} def save_memory(config: dict, store: dict): path = _memory_path(config) with _lock: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: json.dump(store, f, indent=2, ensure_ascii=False) def write_memory(config: dict, owner: str, set_by: str, mem_type: str, key: str, value: Any) -> dict: """Add or update a memory. Returns the written entry.""" store = load_memory(config) memories = store.get("memories", []) # Upsert: remove existing entry with same owner+key+type memories = [m for m in memories if not (m["owner"] == owner and m["key"] == key and m["type"] == mem_type)] entry = { "owner": owner, "set_by": set_by, "type": mem_type, "key": key, "value": value, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"), } memories.append(entry) # Enforce limits player_mems = [m for m in memories if m["owner"] == owner] if len(player_mems) > MAX_PER_PLAYER: oldest = sorted(player_mems, key=lambda m: m["timestamp"]) to_remove = oldest[:len(player_mems) - MAX_PER_PLAYER] remove_keys = {(m["owner"], m["key"], m["type"]) for m in to_remove} memories = [m for m in memories if (m["owner"], m["key"], m["type"]) not in remove_keys] if len(memories) > MAX_TOTAL: memories = sorted(memories, key=lambda m: m["timestamp"])[-MAX_TOTAL:] store["memories"] = memories save_memory(config, store) return entry def read_memory(config: dict, owner: str = None, key: str = None, mem_type: str = None) -> List[dict]: """Query memories. All filters are optional.""" store = load_memory(config) results = store.get("memories", []) if owner: results = [m for m in results if m["owner"].lower() == owner.lower()] if key: results = [m for m in results if m["key"].lower() == key.lower()] if mem_type: results = [m for m in results if m["type"] == mem_type] return sorted(results, key=lambda m: m["timestamp"], reverse=True) def delete_memory(config: dict, owner: str, key: str) -> bool: """Remove a memory by owner+key. Returns True if found and removed.""" store = load_memory(config) before = len(store["memories"]) store["memories"] = [m for m in store["memories"] if not (m["owner"].lower() == owner.lower() and m["key"].lower() == key.lower())] if len(store["memories"]) < before: save_memory(config, store) return True return False def get_location_memories(config: dict) -> List[dict]: """Get all location memories.""" return read_memory(config, mem_type="location") def format_memory_context(config: dict, player: str) -> str: """Format relevant memories for LLM context injection.""" store = load_memory(config) memories = store.get("memories", []) if not memories: return "" lines = ["=== PLAYER MEMORIES ==="] # Player's own memories own = [m for m in memories if m["owner"].lower() == player.lower()] if own: lines.append(f"Your memories ({player}):") for m in own: if m["type"] == "location": v = m["value"] lines.append(f" {m['key']}: x={v.get('x')}, y={v.get('y')}, z={v.get('z')}") else: lines.append(f" {m['key']}: {m['value']}") # Other players' locations (for cross-player references) others = [m for m in memories if m["owner"].lower() != player.lower() and m["type"] == "location"] if others: lines.append("Other players' locations:") for m in others: v = m["value"] lines.append(f" {m['owner']}'s {m['key']}: x={v.get('x')}, y={v.get('y')}, z={v.get('z')}") return "\n".join(lines) # --- Model output handlers --- def handle_memory_write(config: dict, spec: dict, set_by: str) -> dict: """Process a memory_write block from model JSON output.""" owner = spec.get("owner", set_by) mem_type = spec.get("type", "fact") key = spec.get("key", "") value = spec.get("value", "") if not key: return {"ok": False, "error": "key is required"} entry = write_memory(config, owner, set_by, mem_type, key, value) return {"ok": True, "entry": entry} def handle_memory_read(config: dict, spec: dict) -> dict: """Process a memory_read block from model JSON output.""" owner = spec.get("owner") key = spec.get("key") mem_type = spec.get("type") results = read_memory(config, owner=owner, key=key, mem_type=mem_type) return {"ok": True, "results": results} # --- Schema constants for model output --- MEMORY_WRITE_SCHEMA = { "type": "object", "properties": { "owner": {"type": "string", "description": "Player who owns this memory"}, "type": {"type": "string", "enum": ["location", "preference", "fact"]}, "key": {"type": "string", "description": "Memory name (e.g. 'home', 'base', 'favorite_tool')"}, "value": {"description": "Memory data — coordinates for locations, text for others"}, }, "required": ["type", "key", "value"], } MEMORY_READ_SCHEMA = { "type": "object", "properties": { "owner": {"type": "string", "description": "Player to look up (omit for requesting player)"}, "key": {"type": "string", "description": "Memory name to find"}, }, } MODEL_OUTPUT_SCHEMA = { "type": "object", "properties": { "commands": {"type": "array", "items": {"type": "string"}}, "message": {"type": "string"}, "reasoning": {"type": "string"}, "risk_level": {"type": "integer", "minimum": 0, "maximum": 4}, "memory_write": MEMORY_WRITE_SCHEMA, "memory_read": MEMORY_READ_SCHEMA, "revert_after": {"type": "integer", "description": "Seconds before revert_commands execute"}, "revert_commands": {"type": "array", "items": {"type": "string"}}, }, }