"""Memory manager - reads and writes structured markdown memory files. This is MohaMind's persistent brain. All personal data is stored as human-readable markdown files that can be directly edited by the user. """ import hashlib import re from datetime import datetime from pathlib import Path from typing import Optional from moha_mind.agent.provenance import ProvenanceLog from moha_mind.config import settings from moha_mind.utils.logging_config import log from moha_mind.utils.timezone import format_time_en, ksa_today_str, now_ksa MEMORY_FILES = { "profile": "profile.md", "family": "family.md", "tasks": "tasks.md", "reminders": "reminders.md", "occasions": "occasions.md", "vehicle": "vehicle.md", "finances": "finances.md", "health": "health.md", "home": "home.md", "documents": "documents.md", "travel": "travel.md", "learning": "learning.md", "shopping": "shopping.md", "relationships": "relationships.md", "energy_log": "energy_log.md", } MAX_MEMORY_VERSIONS_PER_CATEGORY = 200 def active_task_context(content: str) -> str: lines = [] history_level = None skip_details = False for line in content.splitlines(): heading = re.match(r"^(#{1,6})\s+(.+)$", line) if heading: level = len(heading.group(1)) if history_level is not None and level <= history_level: history_level = None if heading.group(2).strip().casefold() in {"completed", "archived"}: history_level = level skip_details = False if history_level is not None: continue if re.match(r"^\s*- (?:\[[xX]\]|Archived\s+\d{4}-\d{2}-\d{2}:)", line): skip_details = True continue if skip_details and (not line.strip() or line.startswith((" ", "\t"))): continue skip_details = False lines.append(line) return "\n".join(lines) class MemoryManager: def __init__(self, memory_dir: Optional[str] = None): self.memory_path = Path(memory_dir or settings.memory_dir) self.memory_path.mkdir(parents=True, exist_ok=True) (self.memory_path / "daily_log").mkdir(exist_ok=True) (self.memory_path / "notes").mkdir(exist_ok=True) self._scaffold_from_templates() self.provenance = ProvenanceLog(self.memory_path) self._write_source: str = "agent" def _scaffold_from_templates(self) -> None: """On first run, seed missing memory files from memory/templates/. Lets us ship empty structural scaffolds in the public repo while keeping real personal files gitignored. """ templates_dir = self.memory_path / "templates" if not templates_dir.is_dir(): return for category, filename in MEMORY_FILES.items(): target = self.memory_path / filename if target.exists(): continue source = templates_dir / filename if not source.is_file(): continue try: target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") log.info(f"Seeded {category} from template") except OSError as exc: log.debug(f"Could not seed {category} from template: {exc}") def set_write_source(self, source: str) -> None: """Tag upcoming writes with a source (e.g. 'user', 'agent', 'consolidator').""" self._write_source = source or "agent" def read(self, category: str) -> str: """Read a memory file by category name.""" safe_category = category.replace("..", "").replace("/", "").replace("\\", "") filename = MEMORY_FILES.get(safe_category, f"{safe_category}.md") filepath = self.memory_path / filename if filepath.exists(): return filepath.read_text(encoding="utf-8") return "" def _version_category(self, category: str) -> str: return re.sub(r"[^a-zA-Z0-9_-]", "_", category.replace("/", "_").replace("\\", "_")).strip("_") or "memory" def _snapshot_content(self, category: str, content: str, label: str) -> str: if not content: return "" version_dir = self.memory_path / ".versions" / self._version_category(category) version_dir.mkdir(parents=True, exist_ok=True) digest = hashlib.sha1(content.encode("utf-8")).hexdigest()[:12] stamp = now_ksa().strftime("%Y%m%dT%H%M%S%f%z") path = version_dir / f"{stamp}-{label}-{digest}.md" path.write_text(content, encoding="utf-8") self._prune_versions(version_dir) return path.relative_to(self.memory_path).as_posix() def _prune_versions(self, version_dir: Path) -> None: versions = sorted(version_dir.glob("*.md"), key=lambda path: path.stat().st_mtime) excess = len(versions) - MAX_MEMORY_VERSIONS_PER_CATEGORY for path in versions[: max(0, excess)]: try: path.unlink() except OSError as exc: log.debug(f"Could not prune memory version {path}: {exc}") def _read_snapshot(self, relative_path: str | None) -> str: if not relative_path: return "" snapshot_path = (self.memory_path / relative_path).resolve() versions_root = (self.memory_path / ".versions").resolve() try: snapshot_path.relative_to(versions_root) except ValueError: return "" if not snapshot_path.is_file(): return "" return snapshot_path.read_text(encoding="utf-8") def write(self, category: str, content: str, *, action: str = "write", details: Optional[dict] = None) -> None: """Overwrite a memory file. Records a provenance event with the before/after so /undo and /why work. """ safe_category = category.replace("..", "").replace("/", "").replace("\\", "") filename = MEMORY_FILES.get(safe_category, f"{safe_category}.md") filepath = self.memory_path / filename before = filepath.read_text(encoding="utf-8") if filepath.exists() else "" new_content = content.strip() + "\n" record_details = dict(details or {}) before_snapshot = self._snapshot_content(safe_category, before, "before") if before != new_content else "" after_snapshot = self._snapshot_content(safe_category, new_content, "after") if before != new_content else "" if before_snapshot: record_details["before_snapshot"] = before_snapshot if after_snapshot: record_details["after_snapshot"] = after_snapshot filepath.parent.mkdir(parents=True, exist_ok=True) filepath.write_text(new_content, encoding="utf-8") self.provenance.record( action=action, category=safe_category, before=before, after=new_content, source=self._write_source, details=record_details, ) log.info(f"Memory updated: {category}") def append(self, category: str, content: str) -> None: """Append content to a memory file.""" existing = self.read(category) if existing and not existing.endswith("\n"): existing += "\n" self.write(category, existing + content, action="append", details={"added_chars": len(content)}) def save_entry(self, category: str, content: str) -> bool: clean_content = (content or "").strip() if not clean_content: return False existing = self.read(category) if clean_content in existing: return False self.append_to_section(category, "Saved Memory", clean_content) return True def append_to_section(self, category: str, section_header: str, line: str) -> None: """Append a line under a specific ## section in a memory file.""" content = self.read(category) details = {"section": section_header, "line": line} if not content: self.write( category, f"# {category.title()}\n\n## {section_header}\n{line}\n", action="append_section", details=details, ) return section_pattern = f"## {section_header}" if section_pattern in content: parts = content.split(section_pattern, 1) after = parts[1] self.write( category, parts[0] + section_pattern + after.rstrip() + f"\n{line}\n", action="append_section", details=details, ) else: self.write( category, content.rstrip() + f"\n\n## {section_header}\n{line}\n", action="append_section", details=details, ) def delete_line(self, category: str, line_number: int) -> bool: """Delete a specific line (1-indexed) from a memory file. Returns True when the line was deleted, False otherwise. """ content = self.read(category) if not content: return False lines = content.split("\n") idx = line_number - 1 if idx < 0 or idx >= len(lines): return False removed = lines[idx] del lines[idx] self.write( category, "\n".join(lines), action="delete_line", details={"line_number": line_number, "removed_text": removed}, ) log.info(f"Deleted line {line_number} from {category}") return True def delete_matches(self, category: str, query: str, max_deletions: int | None = None) -> int: """Delete lines inside a category that contain the query (case-insensitive). Comment lines (starting with '#' or '\n\n" "## Recurring\n" "\n\n" "## Completed\n" "\n" ), "occasions": ( "# Important Occasions\n\n## Birthdays\n" "\n\n" "## Anniversaries\n\n\n" "## Annual Events\n\n" ), "reminders": ( "# Reminders\n\n## Scheduled\n" "\n\n" "## Completed\n" "\n" ), "vehicle": ( "# Vehicle\n\n## Details\n- Make/Model: \n- Year: \n" "- Plate: \n- Color: \n- VIN: \n- Mileage: \n\n" "## Registration\n- Expires: \n- Renewal reminder: \n\n" "## Insurance\n- Provider: \n- Policy #: \n- Expires: \n" "- Premium: \n\n" "## Service History\n" "\n\n" "## Next Service\n- Due date: \n- Due mileage: \n" ), "finances": ( "# Finances\n\n## Salary\n- Company: \n- Amount: \n" "- Deposit date: 27th monthly\n\n" "## Monthly Bills\n" "\n\n" "## Subscriptions\n" "\n\n" "## Insurance Policies\n" "\n" ), "health": ( "# Health\n\n## Medications\n" "\n\n" "## Doctors\n" "\n\n" "## Gym\n- Schedule: \n" "- Membership expires: \n\n## Health Goals\n" "- Target weight: \n- Diet plan: \n" ), "home": ( "# Home\n\n## Details\n- Address: \n- Rent: SAR/month\n" "- Rent due: 1st monthly\n\n## Utilities\n" "\n\n" "## Appliances & Warranties\n" "\n\n" "## Maintenance Log\n" "\n" ), "documents": ( "# Documents\n\n## Passport\n- Number: \n- Expires: \n\n" "## National ID\n- Number: \n- Expires: \n\n" "## Driver License\n- Number: \n- Expires: \n\n" "## Visa\n- Type: \n- Expires: \n\n" "## Other Documents\n" "\n" ), "travel": ( "# Travel\n\n## Upcoming Trips\n" "\n\n" "## Visa Requirements\n" "\n\n" "## Travel Bucket List\n" "\n" ), "learning": ( "# Learning & Growth\n\n## Current Courses\n" "\n\n" "## Reading List\n" "\n\n" "## Skills Goals\n" "\n\n" "## Certifications\n" "\n" ), "shopping": ( "# Shopping\n\n## Shopping List\n" "\n\n## Preferred Brands\n" "\n\n" "## Sizes (Self)\n- Tops: \n- Pants: \n- Shoes: \n\n" "## Sizes (Family)\n\n" ), "relationships": ( "# Relationships\n\n## Close Friends\n" "\n\n" "## Extended Family\n" "\n\n" "## Gift Ideas\n\n" ), "energy_log": ( "# Energy & Mood Log\n\n## Patterns\n" "\n\n" "## Observations\n" "\n" ), } for category, template in templates.items(): filepath = self.memory_path / MEMORY_FILES[category] if not filepath.exists(): filepath.parent.mkdir(parents=True, exist_ok=True) filepath.write_text(template, encoding="utf-8") log.info(f"Created template: {category}")