feat(conversations): pre-SDK rows read from canonical messages, implied titles, markdown adopts them by first prompt

This commit is contained in:
hh
2026-08-29 01:44:05 +02:00
parent d3a3e7b1f1
commit fd69eae05d
5 changed files with 145 additions and 6 deletions
+74 -1
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio
import contextlib
import inspect
import json
import logging
import re
import uuid
@@ -38,6 +39,7 @@ from claude_agent_sdk import (
)
from sqlmodel import col, select
from beaver_gateway.core.conversation_store import load_messages
from beaver_gateway.core.injects import InjectQueue, inject_header
from beaver_gateway.core.kinds import KINDS, Kind, as_kind
from beaver_gateway.core.transcript import (
@@ -52,13 +54,14 @@ from beaver_gateway.frontends._accumulate import StreamAccumulator
from beaver_gateway.storage.models import (
Conversation,
ConversationBinding,
ConversationMessage,
InjectQueueItem,
RateLimit,
Schedule,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from claude_agent_sdk import SessionStore
@@ -453,6 +456,7 @@ class Conversations:
async def describe(self, conv: Conversation) -> dict[str, Any]:
out = self.public(conv)
out["title"] = await self.implied_title(conv)
parent = await self.get_row(conv.parent_id) if conv.parent_id else None
out["parent"] = parent.external_id if parent is not None else None
out["bindings"] = [
@@ -629,8 +633,67 @@ class Conversations:
return render_messages(await self.history(conv), window=window)
async def history(self, conv: Conversation) -> list[dict[str, Any]]:
if conv.session_id is None:
async with self._db.session() as session:
return await load_messages(
session, conversation_id=cast("int", conv.id)
)
return messages_from_entries(cast("Any", await self.entries(conv)))
async def first_user_texts(self, ids: Iterable[int]) -> dict[int, str]:
wanted = list(ids)
if not wanted:
return {}
async with self._db.session() as session:
rows = (
await session.exec(
select(ConversationMessage).where(
col(ConversationMessage.conversation_id).in_(wanted),
ConversationMessage.seq == 0,
ConversationMessage.role == "user",
)
)
).all()
return {
r.conversation_id: text_of(json.loads(r.content_json)).strip() for r in rows
}
async def implied_title(self, conv: Conversation) -> str | None:
if conv.title:
return conv.title
text = (await self.first_user_texts([cast("int", conv.id)])).get(
cast("int", conv.id)
)
return implied_title(text)
async def adopt(self, *, kind: Kind, first_user_text: str) -> Conversation | None:
"""The one unbound, session-less conversation whose history starts here.
Rows from before the SDK cut-over have canonical messages but no
window and no session; a vault file that begins with the same
prompt is that conversation continued.
"""
text = first_user_text.strip()
if not text:
return None
bound = select(ConversationBinding.conversation_id).where(
col(ConversationBinding.visible).is_(True)
)
async with self._db.session() as session:
rows = (
await session.exec(
select(Conversation).where(
Conversation.kind == kind,
Conversation.status == "open",
col(Conversation.session_id).is_(None),
col(Conversation.id).not_in(bound),
)
)
).all()
firsts = await self.first_user_texts(cast("int", r.id) for r in rows)
hits = [r for r in rows if firsts.get(cast("int", r.id)) == text]
return hits[0] if len(hits) == 1 else None
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
if conv.session_id is None:
return []
@@ -1353,6 +1416,16 @@ def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat(timespec="seconds") if value is not None else None
TITLE_MAX = 80
def implied_title(text: str | None) -> str | None:
if not text:
return None
line = text.strip().splitlines()[0].strip()
return line if len(line) <= TITLE_MAX else line[: TITLE_MAX - 1] + ""
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
if not messages:
return None