165 lines
6.0 KiB
Python
165 lines
6.0 KiB
Python
"""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"])
|