Marcus/Brain/marcus_memory.py

818 lines
31 KiB
Python

"""
marcus_memory.py — Session & Place Memory
==========================================
Project : Marcus | YS Lootah Technology
Hardware : Unitree G1 EDU + Jetson Orin NX
Purpose : Persistent memory across sessions.
- Place memory: save named robot positions, navigate back
- Session memory: log all commands, detections, alerts per session
- Cross-session recall: "what did you see last session?"
Folder structure
----------------
~/Models_marcus/places.json ← persistent named places (all sessions)
~/Models_marcus/sessions/
session_001_2026-04-05/
commands.json ← [{time, cmd, response, duration_s}]
detections.json ← [{time, class, position, distance, x, y}]
places.json ← places saved THIS session
alerts.json ← [{time, type, detail}]
summary.txt ← auto-generated session summary
Import in marcus_brain.py
-------------------------
from marcus_memory import Memory
mem = Memory() ← call once at startup
mem.start_session() ← begins logging
mem.log_command(cmd, response) ← after every command
mem.log_detection(class_name, pos, dist) ← from YOLO loop
mem.save_place("door", x, y, heading) ← when user says "remember this as door"
mem.get_place("door") ← returns {x, y, heading} or None
mem.list_places() ← sorted list of place names
mem.delete_place("door") ← removes place
mem.last_session_summary() ← text summary of previous session
mem.end_session() ← saves everything, call on shutdown
Date : April 2026
"""
import os
import json
import time
import re
import threading
import shutil
import difflib
from datetime import datetime
from pathlib import Path
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════════
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
BASE_DIR = _PROJECT_ROOT / "Data" / "Brain"
SESSIONS_DIR = BASE_DIR / "Sessions"
PLACES_FILE = _PROJECT_ROOT / "Data" / "History" / "Places" / "places.json"
MAX_CMD_LEN = 500 # truncate very long commands
MAX_SESSIONS = 50 # keep last N sessions — older ones auto-deleted
DETECT_DEDUPE = 5.0 # seconds — suppress duplicate YOLO detections
# ══════════════════════════════════════════════════════════════════════════════
# HELPER — SAFE JSON READ/WRITE
# ══════════════════════════════════════════════════════════════════════════════
def _read_json(path: Path, default):
"""
Read JSON file. Returns default if file missing, unreadable, or corrupt.
Backs up corrupt files before resetting.
"""
if not path.exists():
return default
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
# Back up the corrupt file and return default
backup = path.with_suffix(".bak")
try:
shutil.copy(path, backup)
print(f" [Memory] ⚠️ Corrupt JSON at {path.name} — backed up as {backup.name}")
except Exception:
pass
return default
except OSError as e:
print(f" [Memory] ⚠️ Cannot read {path.name}: {e}")
return default
def _write_json(path: Path, data, lock: threading.Lock = None):
"""
Write JSON atomically using a temp file + rename.
Catches disk-full and permission errors without crashing.
Returns True on success.
"""
def _do_write():
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
tmp.replace(path) # atomic rename
return True
except OSError as e:
print(f" [Memory] ⚠️ Cannot write {path.name}: {e}")
return False
if lock:
with lock:
return _do_write()
return _do_write()
def _sanitize_name(name: str) -> str:
"""
Clean a place name: lowercase, strip whitespace, remove unsafe chars.
'Server Room!''server_room'
"""
name = name.strip().lower()
name = re.sub(r"[^\w\s\-]", "", name) # keep word chars, spaces, hyphens
name = re.sub(r"\s+", "_", name) # spaces → underscores
name = name[:50] # max 50 chars
return name
def _fuzzy_match(query: str, choices: list, n: int = 3) -> list:
"""Return up to n closest matches from choices for query."""
if not choices:
return []
return difflib.get_close_matches(query, choices, n=n, cutoff=0.5)
def _new_session_id(sessions_dir: Path) -> str:
"""Generate next session ID: session_001, session_002..."""
existing = [
d.name for d in sessions_dir.iterdir()
if d.is_dir() and d.name.startswith("session_")
] if sessions_dir.exists() else []
nums = []
for name in existing:
parts = name.split("_")
if len(parts) >= 2 and parts[1].isdigit():
nums.append(int(parts[1]))
next_num = max(nums) + 1 if nums else 1
date_str = datetime.now().strftime("%Y-%m-%d")
return f"session_{next_num:03d}_{date_str}"
# ══════════════════════════════════════════════════════════════════════════════
# MEMORY CLASS
# ══════════════════════════════════════════════════════════════════════════════
class Memory:
"""
Persistent session and place memory for Marcus.
Thread-safe. All write operations use locks.
Saves automatically on shutdown via end_session().
Register with atexit for crash protection.
Usage:
mem = Memory()
mem.start_session()
# ... during operation ...
mem.log_command("turn left", "Turning left")
mem.save_place("door", x=1.2, y=0.5, heading=90.0)
# ... on shutdown ...
mem.end_session()
"""
def __init__(self):
self._places_lock = threading.Lock()
self._session_lock = threading.Lock()
self._session_dir = None
self._session_id = None
self._session_start = None
# In-memory buffers — flushed to disk on end_session + periodically
self._commands = [] # [{time, cmd, response, duration_s}]
self._detections = [] # [{time, class, position, distance, x, y}]
self._alerts = [] # [{time, type, detail}]
# Dedup tracking for YOLO detections
self._last_detection = {} # class → timestamp
# Persistent places (survives all sessions)
self._places = {} # {name: {x, y, heading, saved_at, session}}
# Ensure base dirs exist
BASE_DIR.mkdir(parents=True, exist_ok=True)
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
# Load persistent places
self._load_places()
# Register auto-save on crash
import atexit
atexit.register(self._emergency_save)
# ── PLACES ────────────────────────────────────────────────────────────────
def _load_places(self):
"""Load places.json from disk into memory."""
data = _read_json(PLACES_FILE, {})
if isinstance(data, dict):
self._places = data
print(f" [Memory] Places loaded: {len(self._places)} locations")
else:
print(" [Memory] ⚠️ places.json has wrong format — resetting")
self._places = {}
def save_place(self, name: str, x: float = None,
y: float = None, heading: float = None) -> bool:
"""
Save current robot position with a name.
Args:
name : human-readable name e.g. "door", "desk_a"
x : robot x position from odometry (None if not available)
y : robot y position from odometry
heading : robot heading in degrees
Returns:
True on success, False on invalid name or write error.
Edge cases handled:
- Empty name → rejected
- Name with special chars → sanitized
- Duplicate name → overwrites with notification
- Odometry not running (x/y/heading all None) → saved as landmark only
- Disk full → error logged, returns False
"""
# Validate name
if not name or not name.strip():
print(" [Memory] ⚠️ Place name cannot be empty")
return False
clean = _sanitize_name(name)
if not clean:
print(f" [Memory] ⚠️ Place name '{name}' has no valid characters")
return False
# Warn if overwriting
if clean in self._places:
old = self._places[clean]
print(f" [Memory] Overwriting '{clean}' (was saved at {old.get('saved_at','?')})")
# Warn if no odometry
if x is None and y is None:
print(" [Memory] ⚠️ Odometry not running — saving place as landmark only (no coordinates)")
print(" [Memory] 'go to' navigation will not be available for this place")
entry = {
"name": clean,
"x": round(x, 4) if x is not None else None,
"y": round(y, 4) if y is not None else None,
"heading": round(heading, 2) if heading is not None else None,
"has_odom": x is not None,
"saved_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"session": self._session_id or "unknown",
}
with self._places_lock:
self._places[clean] = entry
ok = _write_json(PLACES_FILE, self._places)
if ok:
coord_str = f"x={x:.2f} y={y:.2f} h={heading:.1f}°" if x is not None else "no coordinates"
print(f" [Memory] ✅ Saved place '{clean}'{coord_str}")
return True
return False
def get_place(self, name: str) -> dict:
"""
Get a saved place by name.
Returns:
dict with x, y, heading, has_odom — or None if not found.
Suggests similar names if not found.
Edge cases:
- Exact match first
- Case-insensitive match
- Fuzzy match suggestion if no exact match
"""
clean = _sanitize_name(name)
with self._places_lock:
places = dict(self._places)
# Exact match
if clean in places:
return places[clean]
# Case-insensitive match
for k, v in places.items():
if k.lower() == clean.lower():
return v
# Fuzzy suggestions
suggestions = _fuzzy_match(clean, list(places.keys()))
if suggestions:
print(f" [Memory] ⚠️ Place '{name}' not found.")
print(f" [Memory] Did you mean: {', '.join(suggestions)} ?")
else:
print(f" [Memory] ⚠️ Place '{name}' not found.")
if places:
print(f" [Memory] Known places: {', '.join(sorted(places.keys()))}")
else:
print(" [Memory] No places saved yet. Say 'remember this as <name>'")
return None
def delete_place(self, name: str) -> bool:
"""
Remove a saved place.
Returns True if deleted, False if not found.
"""
clean = _sanitize_name(name)
with self._places_lock:
if clean not in self._places:
suggestions = _fuzzy_match(clean, list(self._places.keys()))
if suggestions:
print(f" [Memory] Place '{name}' not found. Did you mean: {', '.join(suggestions)}?")
else:
print(f" [Memory] Place '{name}' not found.")
return False
del self._places[clean]
ok = _write_json(PLACES_FILE, self._places)
if ok:
print(f" [Memory] ✅ Deleted place '{clean}'")
return True
# Restore on write failure
self._load_places()
return False
def list_places(self) -> list:
"""
Return sorted list of place name dicts.
Returns:
[{"name": str, "x": float|None, "y": float|None,
"heading": float|None, "has_odom": bool, "saved_at": str}]
"""
with self._places_lock:
places = list(self._places.values())
return sorted(places, key=lambda p: p.get("name", ""))
def rename_place(self, old_name: str, new_name: str) -> bool:
"""Rename a saved place."""
old_clean = _sanitize_name(old_name)
new_clean = _sanitize_name(new_name)
if not new_clean:
print(f" [Memory] ⚠️ New name '{new_name}' is invalid")
return False
with self._places_lock:
if old_clean not in self._places:
print(f" [Memory] ⚠️ Place '{old_name}' not found")
return False
if new_clean in self._places:
print(f" [Memory] ⚠️ Name '{new_clean}' already exists")
return False
entry = self._places.pop(old_clean)
entry["name"] = new_clean
self._places[new_clean] = entry
ok = _write_json(PLACES_FILE, self._places)
if ok:
print(f" [Memory] ✅ Renamed '{old_clean}''{new_clean}'")
return True
self._load_places()
return False
# ── SESSION ───────────────────────────────────────────────────────────────
def start_session(self):
"""
Begin a new session. Creates session folder, sets session ID.
Edge cases:
- sessions/ doesn't exist → created
- session ID collision (same date, same number) → incremented
- Can only call once — subsequent calls are no-ops with warning
"""
if self._session_id is not None:
print(f" [Memory] ⚠️ Session already running: {self._session_id}")
return
self._session_id = _new_session_id(SESSIONS_DIR)
self._session_dir = SESSIONS_DIR / self._session_id
self._session_start = time.time()
try:
self._session_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
print(f" [Memory] ⚠️ Cannot create session dir: {e}")
self._session_id = None
self._session_dir = None
return
# Load previous session summary for context
prev = self._get_previous_session_dir()
if prev:
print(f" [Memory] Previous session: {prev.name}")
else:
print(" [Memory] First session — no previous memory")
print(f" [Memory] ✅ Session started: {self._session_id}")
# Auto-flush every 60 seconds in background
self._start_autosave()
def _start_autosave(self):
"""Background thread — flush session to disk every 60s."""
def _loop():
while self._session_id is not None:
time.sleep(60)
self._flush_session()
threading.Thread(target=_loop, daemon=True).start()
def _flush_session(self):
"""Write current session buffers to disk without closing session."""
if self._session_dir is None:
return
with self._session_lock:
_write_json(self._session_dir / "commands.json", self._commands)
_write_json(self._session_dir / "detections.json", self._detections)
_write_json(self._session_dir / "alerts.json", self._alerts)
# Save copy of places as they were this session
with self._places_lock:
_write_json(self._session_dir / "places.json", self._places)
def log_command(self, cmd: str, response: str = "", duration_s: float = 0.0):
"""
Log a command and its response to the current session.
Args:
cmd : the command string typed/spoken
response : Marcus's spoken response
duration_s : how long LLaVA took to respond
Edge cases:
- No active session → logs to memory only, warns
- cmd too long → truncated at MAX_CMD_LEN
- Unicode (Arabic) → preserved via ensure_ascii=False
- Thread-safe
"""
if not cmd:
return
entry = {
"time": datetime.now().strftime("%H:%M:%S"),
"cmd": cmd[:MAX_CMD_LEN],
"response": response[:MAX_CMD_LEN] if response else "",
"duration_s": round(duration_s, 2),
}
with self._session_lock:
self._commands.append(entry)
if self._session_dir is None:
print(" [Memory] ⚠️ No active session — command logged in memory only")
def log_detection(self, class_name: str, position: str = "",
distance: str = "", x: float = None, y: float = None):
"""
Log a YOLO detection to the current session.
Deduplicates: same class within DETECT_DEDUPE seconds is suppressed.
Args:
class_name : e.g. "person"
position : "left" / "center" / "right"
distance : "close" / "medium" / "far"
x, y : robot position when detection occurred (from odometry)
"""
if not class_name:
return
# Deduplicate — don't log same class repeatedly within dedupe window
now = time.time()
last = self._last_detection.get(class_name, 0)
if now - last < DETECT_DEDUPE:
return
self._last_detection[class_name] = now
entry = {
"time": datetime.now().strftime("%H:%M:%S"),
"class": class_name,
"position": position,
"distance": distance,
"x": round(x, 3) if x is not None else None,
"y": round(y, 3) if y is not None else None,
}
with self._session_lock:
self._detections.append(entry)
def log_alert(self, alert_type: str, detail: str = ""):
"""
Log a PPE or hazard alert to the current session.
Args:
alert_type : "PPE", "Hazard", "obstacle", etc.
detail : e.g. "no helmet (left)", "fire extinguisher missing"
"""
entry = {
"time": datetime.now().strftime("%H:%M:%S"),
"type": alert_type,
"detail": detail[:200],
}
with self._session_lock:
self._alerts.append(entry)
print(f" [Memory] 🚨 Alert logged: {alert_type}{detail}")
def get_last_command(self) -> str:
"""Return the last command typed, or empty string."""
with self._session_lock:
if self._commands:
return self._commands[-1].get("cmd", "")
return ""
def get_last_n_commands(self, n: int = 5) -> list:
"""Return last N command strings for LLaVA context."""
with self._session_lock:
recent = self._commands[-n:] if len(self._commands) >= n else self._commands[:]
return [e.get("cmd", "") for e in recent]
def get_session_detections(self) -> list:
"""Return all YOLO detections this session."""
with self._session_lock:
return list(self._detections)
def end_session(self):
"""
Save everything and close the current session.
Call on clean shutdown or Ctrl+C.
Edge cases:
- No active session → no-op
- Disk full → logs error but doesn't crash
- Generates summary text file
- Cleans up old sessions if > MAX_SESSIONS
"""
if self._session_id is None:
return
print(f"\n [Memory] Saving session {self._session_id}...")
self._flush_session()
self._write_summary()
self._prune_old_sessions()
elapsed = time.time() - (self._session_start or time.time())
mins = int(elapsed // 60)
secs = int(elapsed % 60)
with self._session_lock:
n_cmds = len(self._commands)
n_dets = len(self._detections)
n_alerts= len(self._alerts)
print(f" [Memory] ✅ Session saved: {self._session_id}")
print(f" [Memory] Duration: {mins}m {secs}s")
print(f" [Memory] Commands: {n_cmds} | Detections: {n_dets} | Alerts: {n_alerts}")
# Reset state
self._session_id = None
self._session_dir = None
def _emergency_save(self):
"""Called by atexit on crash — saves partial session data."""
if self._session_id is None:
return
print("\n [Memory] Emergency save on exit...")
self._flush_session()
def _write_summary(self):
"""Generate a plain text summary of the session."""
if self._session_dir is None:
return
try:
with self._session_lock:
n_cmds = len(self._commands)
n_dets = len(self._detections)
n_alerts = len(self._alerts)
places = list(self._places.keys())
cmds = [e.get("cmd", "") for e in self._commands[:10]]
elapsed = time.time() - (self._session_start or time.time())
lines = [
f"Session: {self._session_id}",
f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
f"Duration: {int(elapsed//60)}m {int(elapsed%60)}s",
f"Commands: {n_cmds}",
f"YOLO detections: {n_dets}",
f"Alerts: {n_alerts}",
f"Known places: {', '.join(places) if places else 'none'}",
"",
"First commands:",
] + [f" - {c}" for c in cmds]
with open(self._session_dir / "summary.txt", "w", encoding="utf-8") as f:
f.write("\n".join(lines))
except Exception as e:
print(f" [Memory] ⚠️ Summary write failed: {e}")
def _prune_old_sessions(self):
"""Delete oldest sessions if total exceeds MAX_SESSIONS."""
try:
sessions = sorted(
[d for d in SESSIONS_DIR.iterdir() if d.is_dir()],
key=lambda d: d.stat().st_mtime
)
while len(sessions) > MAX_SESSIONS:
oldest = sessions.pop(0)
shutil.rmtree(oldest, ignore_errors=True)
print(f" [Memory] Pruned old session: {oldest.name}")
except Exception as e:
print(f" [Memory] ⚠️ Session pruning failed: {e}")
# ── PREVIOUS SESSION RECALL ───────────────────────────────────────────────
def _get_previous_session_dir(self) -> Path:
"""Return the most recent session dir that is NOT the current session."""
try:
dirs = sorted(
[d for d in SESSIONS_DIR.iterdir()
if d.is_dir() and d.name != self._session_id],
key=lambda d: d.stat().st_mtime,
reverse=True
)
return dirs[0] if dirs else None
except Exception:
return None
def last_session_summary(self) -> str:
"""
Return text summary of the most recent previous session.
Used when user says 'what did you do last session?'
Edge cases:
- No previous session → informative message
- Summary file missing → reconstruct from commands.json
- Commands file corrupt → return partial info
"""
prev = self._get_previous_session_dir()
if prev is None:
return "No previous session found. This appears to be the first session."
# Try summary.txt first
summary_file = prev / "summary.txt"
if summary_file.exists():
try:
return summary_file.read_text(encoding="utf-8")
except Exception:
pass
# Reconstruct from raw data
cmds = _read_json(prev / "commands.json", [])
dets = _read_json(prev / "detections.json", [])
pls = _read_json(prev / "places.json", {})
lines = [
f"Previous session: {prev.name}",
f"Commands logged: {len(cmds)}",
f"Objects detected: {len(dets)}",
f"Places saved: {', '.join(pls.keys()) if pls else 'none'}",
]
if cmds:
lines.append("Commands included:")
for c in cmds[:10]:
lines.append(f" - {c.get('cmd','')}")
return "\n".join(lines)
def previous_session_detections(self) -> list:
"""
Return list of unique object classes seen in previous session.
Used for 'what objects did you see last time?'
"""
prev = self._get_previous_session_dir()
if prev is None:
return []
dets = _read_json(prev / "detections.json", [])
if not isinstance(dets, list):
return []
return list({d.get("class", "") for d in dets if d.get("class")})
def previous_session_places(self) -> dict:
"""
Return places saved in the previous session.
"""
prev = self._get_previous_session_dir()
if prev is None:
return {}
data = _read_json(prev / "places.json", {})
return data if isinstance(data, dict) else {}
def all_sessions(self) -> list:
"""
Return list of all session IDs with basic stats.
"""
result = []
try:
for d in sorted(SESSIONS_DIR.iterdir(), reverse=True):
if not d.is_dir():
continue
cmds = _read_json(d / "commands.json", [])
result.append({
"id": d.name,
"commands": len(cmds) if isinstance(cmds, list) else 0,
"date": "_".join(d.name.split("_")[2:]) if "_" in d.name else "",
})
except Exception:
pass
return result
# ── QUICK LOOKUPS ─────────────────────────────────────────────────────────
def session_duration_str(self) -> str:
"""Return human-readable session duration e.g. '14m 22s'."""
if self._session_start is None:
return "0m 0s"
elapsed = time.time() - self._session_start
return f"{int(elapsed//60)}m {int(elapsed%60)}s"
def places_count(self) -> int:
with self._places_lock:
return len(self._places)
def commands_count(self) -> int:
with self._session_lock:
return len(self._commands)
def __repr__(self):
return (f"Memory(session={self._session_id}, "
f"places={self.places_count()}, "
f"commands={self.commands_count()})")
# ══════════════════════════════════════════════════════════════════════════════
# STANDALONE TEST
# ══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
print("Marcus Memory — Standalone Test")
print("=================================\n")
mem = Memory()
mem.start_session()
print("\n--- Place memory ---")
mem.save_place("door", x=0.0, y=0.0, heading=0.0)
mem.save_place("desk a", x=1.5, y=0.3, heading=45.0)
mem.save_place("window", x=3.0, y=0.0, heading=180.0)
mem.save_place("", x=0.0, y=0.0, heading=0.0) # empty name → rejected
mem.save_place("desk a", x=1.6, y=0.4, heading=50.0) # duplicate → overwrite
print("\n--- Get place ---")
p = mem.get_place("door")
print(f"door → {p}")
p = mem.get_place("dooor") # typo → fuzzy suggestion
p = mem.get_place("nonexistent") # not found + list all
print("\n--- List places ---")
for pl in mem.list_places():
odom = f"x={pl['x']} y={pl['y']}" if pl['has_odom'] else "no odom"
print(f" {pl['name']:20} {odom}")
print("\n--- Command logging ---")
mem.log_command("turn left", "Turning left", duration_s=6.2)
mem.log_command("what do you see", "I see a person at a desk", duration_s=7.1)
mem.log_command("go to door", "Navigating to door", duration_s=0.1)
print(f"Last command: '{mem.get_last_command()}'")
print(f"Last 3: {mem.get_last_n_commands(3)}")
print("\n--- Detection logging ---")
mem.log_detection("person", "center", "close", x=1.5, y=0.3)
mem.log_detection("person", "center", "close", x=1.5, y=0.3) # dedupe → suppressed
mem.log_detection("chair", "right", "medium", x=1.5, y=0.3)
print("\n--- Alert logging ---")
mem.log_alert("PPE", "no helmet (left)")
mem.log_alert("Hazard", "fire extinguisher missing")
print("\n--- Previous session ---")
print(mem.last_session_summary())
print("\n--- All sessions ---")
for s in mem.all_sessions():
print(f" {s['id']:40} {s['commands']} commands")
print(f"\n--- Session duration: {mem.session_duration_str()} ---")
print(f"--- {repr(mem)} ---\n")
print("\n--- Rename place ---")
mem.rename_place("window", "window_north")
print("\n--- Delete place ---")
mem.delete_place("door")
mem.delete_place("nonexistent")
print("\n--- End session ---")
mem.end_session()
print("\nDone.")