818 lines
31 KiB
Python
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.")
|