"""Смена мастера и закрытие чата: утренний сид, хендаут, «новый день», дистиллятор.""" from __future__ import annotations import time from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING from beaver_gateway.frontends.turn_record import slugify from beaver_agent.memory.recall import notes_head from beaver_agent.vault import ( DAYS, DIARY, DIGESTS, GRANULES, PORTRAIT, STATE, VAULT, ZONE, read, ) if TYPE_CHECKING: from beaver_gateway.conversations.distill import DistillContext from beaver_gateway.conversations.rotation import HandoutContext from beaver_gateway.conversations.texts import NewDayContext, SeedContext def latest_handout() -> Path | None: """Последний хендаут, а не «вчерашний по календарю».""" files = sorted(DAYS.glob("????-??-??.md")) if DAYS.exists() else [] return files[-1] if files else None PROC_INIT = Path("/proc/1") """mtime PID 1 - момент старта контейнера, проверено в бою.""" PACKAGE = Path(__file__).resolve().parent.parent """Корень пакета: свежесть кода меряем от себя, а не от пути в контейнере.""" DEPLOY_MARK = "Выкат: процесс поднят {start} ({ago}), свежесть кода {code} - {verdict}." CODE_OLDER = "код старше старта, значит крутится то, что выкатывали" CODE_NEWER = "код НОВЕЕ старта: правка на диске есть, рестарта после неё не было" SAME_MINUTE = "выкат и правка в одну минуту" HOUR = 3600 def _ago(seconds: float) -> str: """Целыми часами, а меньше часа - минутами.""" if seconds < HOUR: return f"{int(seconds // 60)} мин назад" return f"{int(seconds // HOUR)} ч назад" def _clock(stamp: float) -> str: return f"{datetime.fromtimestamp(stamp, ZONE):%Y-%m-%d %H:%M}" def deploy_mark(proc: Path = PROC_INIT, package: Path = PACKAGE) -> str | None: """Когда подняли процесс и насколько свеж код, который в нём крутится. Две цифры рядом: mtime PID 1 - старт контейнера, последний изменённый `*.py` пакета - свежесть исходников. Порознь каждая врёт: код бывает новее старта, и тогда правка на диске есть, а рестарта после неё не было. Любая осечка (нет `/proc`, нет прав, пустой пакет) - None: сид не имеет права упасть из-за этой отметки. """ try: started = proc.stat().st_mtime sources = [ path.stat().st_mtime for path in package.rglob("*.py") if "__pycache__" not in path.parts ] if not sources: return None code = max(sources) gap = code - started if abs(gap) < 60: verdict = SAME_MINUTE elif gap > 0: verdict = CODE_NEWER else: verdict = CODE_OLDER return DEPLOY_MARK.format( start=_clock(started), ago=_ago(time.time() - started), code=_clock(code), verdict=verdict, ) except (OSError, ValueError): return None def seed_body(ctx: SeedContext) -> str | None: """Утренний сид: хендаут, шапка состояния, портрет Бобра, отметка о выкате.""" if ctx.seed != "morning": return None handout = latest_handout() if handout is None: return "Хендаут не приехал." parts = [ f"Хендаут ({handout.stem}):\n\n{handout.read_text(encoding='utf-8').strip()}" ] signal = "\n".join((read(STATE) or "").splitlines()[:5]) if signal: parts.append( f"Состояние (первые строки, полностью в {STATE.relative_to(VAULT)}):\n" f"{signal}" ) portrait = notes_head(PORTRAIT, ("сейчас", "паттерны"), cap=40) if portrait: parts.append( "Портрет Бобра (шапка " f"`{PORTRAIT.relative_to(VAULT)}`, лента - там же):\n{portrait}" ) mark = deploy_mark() if mark: parts.append(mark) return "\n\n".join(parts) DEFAULT_HANDOUT = ( "Этот мастер закрывается ({reason}). Напиши хендаут за {day} в " "`мета/бобер/дни/{day}.md`: справку на утро, не задание." ) def handout_prompt(ctx: HandoutContext) -> str: day = ctx.day.isoformat() body = ( (read(GRANULES / "хендаут.md") or DEFAULT_HANDOUT) .replace("{day}", day) .replace("{reason}", ctx.reason) ) if (DIARY / f"{day}.md").exists(): return f"{body}\n\nДневник за {day} приехал: `📅 дни/{day}.md`, возьми из него." return f"{body}\n\nДневник за {day} не приехал - так и напиши в разделе «дневник»." NEW_DAY = ( "Новый день ({day}). Ты - новый мастер того же треда: хендаут и сигнал состояния " "в сиде выше. Скажи Бобру что-то через say, только если есть что сказать." ) SAME_DAY = ( "Мастер пересоздан ({why}), день тот же - {day}. Хендаут в сиде выше - справка от " "предыдущего мастера за сегодня, не утро: продолжай с того места, ничего не " "приветствуй. Скажи Бобру что-то через say, только если есть что сказать." ) ROTATION_WHY = { "age": "прошло больше 36 часов", "context": "контекст стал слишком большим", } def new_day(ctx: NewDayContext) -> str: """«Новый день» - только ночная ротация; остальные причины - тот же день.""" day = ctx.day.isoformat() if ctx.reason == "night": return NEW_DAY.format(day=day) return SAME_DAY.format(day=day, why=ROTATION_WHY.get(ctx.reason, ctx.reason)) DISTILL = ( "Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Выжимка - `Write` в " '`{path}`, фронтматтер `source: "[[{chat}]]"`, `date: {day}`; слив - текст ' "ответа, до 5 строк, третье лицо." ) DISTILL_NO_MEMORY = ( "Глубокий чат [[{chat}]] закрыт ({reason}), сегодня {day}. Память для него " "выключена: файл не пиши, только слив текстом ответа - до 5 строк, третье лицо." ) def distill_prompt(ctx: DistillContext) -> str: day = ctx.day.isoformat() if not ctx.memory: return DISTILL_NO_MEMORY.format(chat=ctx.chat_name, reason=ctx.reason, day=day) path = DIGESTS / f"{day} - {slugify(ctx.chat_name, maxlen=60)}.md" return DISTILL.format(chat=ctx.chat_name, reason=ctx.reason, day=day, path=path)