"""Vault files for ``deep`` conversations that were not typed into a file. ``materialize`` creates the file for a conversation spawned elsewhere; ``run`` appends replies produced by other origins and keeps the canonical history in sync so a continuation typed in Obsidian resumes the same session. """ from __future__ import annotations import asyncio import logging from datetime import UTC, datetime from typing import TYPE_CHECKING, Any import frontmatter from beaver_gateway.frontends.markdown import renderer from beaver_gateway.frontends.markdown.crossfront import strip_trailing_user_scaffold from beaver_gateway.frontends.markdown.files import ( read_or_empty, reattach_frontmatter, write_atomic, ) from beaver_gateway.frontends.markdown.history import load_messages, rewrite_messages from beaver_gateway.frontends.turn_record import slugify if TYPE_CHECKING: from collections.abc import Callable, Sequence from pathlib import Path from beaver_gateway.events.bus import Event from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.storage.models import Conversation, ConversationBinding __all__ = ["FRONTEND", "ChatMirror"] _log = logging.getLogger("beaver_gateway.frontends.markdown.mirror") FRONTEND = "markdown" class ChatMirror: def __init__( self, *, vault_path: Path, runtime: GatewayRuntime, logged_subdir: str, chat_path: Callable[[str, str, Path], Path] | None = None, ) -> None: self._vault = vault_path self._runtime = runtime self._root = vault_path / logged_subdir self._chat_path_fn = chat_path self._lock = asyncio.Lock() def path_for(self, title: str, agent: str) -> Path: if self._chat_path_fn is not None: path = self._chat_path_fn(title, agent, self._vault) if not path.is_absolute(): path = self._vault / path else: day = datetime.now(UTC).strftime("%Y-%m-%d") path = self._root / agent / f"{day}_{slugify(title, maxlen=60)}.md" if path.suffix != ".md": path = path.with_suffix(".md") path = path.resolve() path.relative_to(self._vault) candidate, n = path, 1 while candidate.exists(): n += 1 candidate = path.with_name(f"{path.stem} ({n}){path.suffix}") return candidate async def materialize(self, conv: Conversation) -> ConversationBinding: path = self.path_for(conv.title or conv.external_id, conv.agent_name) rel = path.relative_to(self._vault).as_posix() async with self._lock: await write_atomic(path, reattach_frontmatter(_frontmatter(conv), "")) binding = await self._runtime.conversations.bind( conv, frontend=FRONTEND, external_id=rel ) _log.info("materialized %s as %s", conv.external_id, rel) return binding async def bound_path(self, conv: Conversation) -> Path | None: for b in await self._runtime.conversations.bindings(conv): if b.frontend == FRONTEND and b.visible: return self._vault / b.external_id return None async def run(self) -> None: async for event in self._runtime.bus.stream(): if event.get("type") != "reply": continue try: await self._on_reply(event) except Exception: # noqa: BLE001 _log.exception("mirror of reply %s failed", event.get("turn_id")) async def _on_reply(self, event: Event) -> None: conv = await self._runtime.conversations.get(str(event["conversation_id"])) if conv is None: return await self.append( conv, prompt=str(event.get("prompt") or ""), text=str(event.get("text") or ""), history=event.get("history"), ) async def append( self, conv: Conversation, *, prompt: str, text: str, history: Sequence[dict[str, Any]] | None = None, ) -> None: path = await self.bound_path(conv) if path is None: return prior = [ {"role": str(h["role"]), "content": str(h["text"])} for h in history or () if h.get("text") ] async with self._lock: parsed = frontmatter.loads(await read_or_empty(path)) body = strip_trailing_user_scaffold(parsed.content) if prior and "### " not in body: for m in prior: body = renderer.append_to_body(body, _render(m)) body = renderer.append_to_body(body, renderer.render_user_text(prompt)) body = renderer.append_to_body(body, renderer.render_assistant_text(text)) body = renderer.append_to_body(body, renderer.USER_SCAFFOLD) metadata = {**parsed.metadata, **_frontmatter(conv)} metadata.pop("fingerprint", None) await write_atomic(path, reattach_frontmatter(metadata, body)) await self._persist(conv, prior=prior, prompt=prompt, text=text) async def _persist( self, conv: Conversation, *, prior: list[dict[str, Any]], prompt: str, text: str ) -> None: if conv.id is None: return async with self._runtime.db.session() as session: stored = await load_messages(session, conversation_id=conv.id) canonical = [ *(stored or prior), {"role": "user", "content": prompt}, {"role": "assistant", "content": [{"type": "text", "text": text}]}, ] await rewrite_messages(session, conversation_id=conv.id, messages=canonical) def _frontmatter(conv: Conversation) -> dict[str, Any]: return {"agent": conv.agent_name, "conversation_id": conv.external_id} def _render(message: dict[str, Any]) -> str: if message["role"] == "user": return renderer.render_user_text(message["content"]) return renderer.render_assistant_text(message["content"])