""" 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 ---------------- Data/History/Places/places.json ← persistent named places (all sessions) Data/Brain/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 '") 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.")