"""Rolling per-category memory summaries. For each memory category (tasks, family, reminders, ...), we keep a short paragraph summary in `memory/.summaries/.md`. Summaries are regenerated on demand when the source file mtime is newer than the summary mtime. Why: the system prompt used to paste every category in full every turn, which blows up the token budget as memory grows. With summaries + a router (see memory_router.py) we only inject summaries by default and expand the 2-4 most relevant categories in full. The summarizer tries to use the primary LLM for quality. If the LLM is unavailable (e.g. tests, no API key), it falls back to a deterministic heuristic summary built from headings + first bullets. """ from __future__ import annotations import asyncio import hashlib import json import re from dataclasses import dataclass from pathlib import Path from typing import Optional from moha_mind.agent.memory import MEMORY_FILES, MemoryManager, active_task_context from moha_mind.config import settings from moha_mind.utils.logging_config import log SUMMARIES_DIR = ".summaries" MAX_SUMMARY_CHARS = 600 @dataclass class CategorySummary: category: str text: str source_mtime: float generated_at: float def _heuristic_summary(category: str, content: str, max_chars: int = MAX_SUMMARY_CHARS) -> str: """Deterministic fallback: pull sections + first bullet per section. The result is not brilliant, but it's always available and is small. """ if category == "tasks": content = active_task_context(content) if not content or not content.strip(): return f"(empty) {category} has no entries yet." lines = [line.rstrip() for line in content.splitlines()] sections: list[tuple[str, list[str]]] = [] current_name = "intro" current_lines: list[str] = [] for line in lines: stripped = line.strip() if re.match(r"^#{2,6}\s+", stripped): if current_lines: sections.append((current_name, current_lines)) current_name = stripped.lstrip("# ").strip() current_lines = [] continue if stripped.startswith("# "): continue if stripped.startswith("