8158178a56
player_memory.py: - Per-server JSON with owner tagging, cross-player references - write/read/delete with thread safety and limits (50/player, 500/server) - format_memory_context() for LLM prompt injection - handle_memory_write/read for model output processing - MODEL_OUTPUT_SCHEMA with commands, memory_write, memory_read, revert_after mortdecai-sites (CT 650): - Whitelist app migrated from CT 644, RCON via LAN (192.168.0.244) - All 4 sites verified: mortdec.ai, docs, git, minecraft Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
6.9 KiB
Python
202 lines
6.9 KiB
Python
"""
|
|
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"}},
|
|
},
|
|
}
|