"""Куратор памяти: что нового с прошлого прогона (архитектура §4.5, §6.0). Крон дёргает детерминированный код, модель судит: здесь собирается брифинг для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор сам пишет первой строкой `мета/бобер/куратор.md`; нет ничего нового - брифинга нет и джоб не спавнится. """ from __future__ import annotations import re from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING from zoneinfo import ZoneInfo if TYPE_CHECKING: from pathlib import Path LAST_RUN_RE = re.compile(r"^последний прогон:\s*(\S+)") REPLY_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}) · ") MAX_REPLY_CHARS = 60_000 @dataclass(frozen=True) class Watched: label: str root: Path pattern: str = "**/*.md" def last_run(journal: Path, *, default: datetime) -> datetime: """`последний прогон: ` из начала журнала, иначе ``default``.""" if not journal.exists(): return default for line in journal.read_text(encoding="utf-8", errors="ignore").splitlines()[:5]: m = LAST_RUN_RE.match(line.strip()) if m: try: stamp = datetime.fromisoformat(m.group(1)) except ValueError: return default return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC) return default def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]: """Строки `реплики/YYYY-MM.md` позже ``since`` (время в строках локальное).""" zone = ZoneInfo(tz) local_since = since.astimezone(zone).replace(tzinfo=None) out: list[str] = [] if not replies_dir.exists(): return out for path in sorted(replies_dir.glob("????-??.md"))[-2:]: for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): m = REPLY_RE.match(line) if not m: continue try: at = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M") # noqa: DTZ007 except ValueError: continue if at > local_since: out.append(line) return out def changed_files( watched: list[Watched], since: datetime, vault: Path, *, skip: tuple[str, ...] = () ) -> list[str]: """`путь (размер)` для файлов с mtime позже ``since``; ``skip`` - подстроки.""" out: list[tuple[str, str]] = [] stamp = since.timestamp() for w in watched: if not w.root.exists(): continue for path in w.root.glob(w.pattern): if not path.is_file() or path.name.startswith("."): continue rel = str(path.relative_to(vault)) if any(s in rel for s in skip): continue if path.stat().st_mtime > stamp: out.append((w.label, f"`{rel}` ({path.stat().st_size // 1024} КБ)")) out.sort() return [f"{label}: {desc}" for label, desc in out] def briefing( *, vault: Path, journal: Path, replies_dir: Path, watched: list[Watched], tz: str, now: datetime | None = None, default_window: timedelta = timedelta(hours=24), ) -> str | None: """Первое сообщение куратору или ``None``, если с прошлого прогона ничего нет.""" now = now or datetime.now(UTC) since = last_run(journal, default=now - default_window) replies = new_replies(replies_dir, since, tz) changed = changed_files( watched, since, vault, skip=(str(journal.relative_to(vault)),) ) if not replies and not changed: return None zone = ZoneInfo(tz) head = ( f"Прогон куратора {now.astimezone(zone):%Y-%m-%d %H:%M} ({tz}); прошлый - " f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; " "остальное читай сам по слоям. В конце обнови первую строку " f"`{journal.relative_to(vault)}`: `последний прогон: " f"{now.astimezone(zone).isoformat(timespec='seconds')}`." ) parts = [head] if replies: text = "\n".join(replies) if len(text) > MAX_REPLY_CHARS: text = text[-MAX_REPLY_CHARS:] text = "…(обрезано, начало - в файле)\n" + text[text.find("\n") + 1 :] parts.append(f"Новые реплики Бобра ({len(replies)}):\n{text}") else: parts.append("Новых реплик Бобра нет.") if changed: parts.append("Изменились файлы:\n" + "\n".join(f"- {c}" for c in changed[:80])) return "\n\n".join(parts)