refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text

This commit is contained in:
hh committed 2026-09-02 00:13:20 +02:00
1 parent 253bde1b11
commit 9aaddbed75
77 files changed
+2987 -2944

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
"""Cross-frontend turn record.
Frontends that finish a turn (the Anthropic Messages frontend, the
markdown frontend) emit a :class:`TurnRecord` to every handler in
``GatewayRuntime.turn_log_handlers``. The markdown frontend uses this
to persist chats from other frontends into the Obsidian vault — see
``frontends/markdown/crossfront.py``.
Kept tiny on purpose: it carries the structured-enough payload a logger
needs (which agent ran, input history, the assembled assistant reply)
and nothing else. The full event stream is gone by the time handlers
run — if a future consumer needs deltas it would subscribe at a lower
level, not here.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from anthropic.types import Message, MessageParam
__all__ = ["TurnRecord", "slugify"]
# Filesystem-safe slug: collapse anything that isn't a word char or
# space/hyphen to a hyphen, then squash runs of separators. Aimed at
# letting users build filenames from ``record.first_user_text`` without
# hand-rolling sanitization in every config.
_SLUG_BAD_RE = re.compile(r"[^\w\s\-]+", flags=re.UNICODE)
_SLUG_SEP_RE = re.compile(r"[\s\-]+", flags=re.UNICODE)
def slugify(text: str, *, maxlen: int = 40) -> str:
"""Sanitize ``text`` for use as a filename fragment.
Strips punctuation, collapses whitespace/hyphens into single ``-``,
and truncates to ``maxlen``. Returns ``"untitled"`` for empty input.
Unicode letters are preserved (Obsidian handles them fine; macOS
and modern Linux fs's too).
"""
cleaned = _SLUG_BAD_RE.sub(" ", text).strip()
cleaned = _SLUG_SEP_RE.sub("-", cleaned).strip("-")
if not cleaned:
return "untitled"
if len(cleaned) > maxlen:
cleaned = cleaned[:maxlen].rstrip("-")
return cleaned or "untitled"
@dataclass(frozen=True, slots=True)
class TurnRecord:
"""One completed turn, as seen by a frontend.
``input_messages`` is the conversation history sent to the backend
(everything *before* the assistant reply). ``output_message`` is the
finalized assistant ``Message`` (post-accumulation, with all content
blocks attached). ``system`` is the per-request system prompt if any
— agents own their own ``system_prompt``, this is the override the
caller passed; most handlers can ignore it.
``source`` names the frontend that produced the record so the
cross-frontend logger can avoid logging its own turns (markdown
frontend writing the file would otherwise also receive its own
broadcast and double-write).
"""
agent_name: str
input_messages: list[MessageParam]
output_message: Message
system: str | None = None
source: str = "anthropic"
@property
def first_user_text(self) -> str:
"""Plain text of the *earliest* user turn in this conversation.
Useful for naming new files by topic. Empty string if the input
history somehow has no user turn (shouldn't happen — turns are
broadcast only after a user → assistant cycle).
"""
for msg in self.input_messages:
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
return ""
@property
def last_user_text(self) -> str:
"""Plain text of the most recent user turn (the trigger)."""
for msg in reversed(self.input_messages):
if msg.get("role") == "user":
return _text_of(msg.get("content", ""))
return ""
@property
def assistant_text(self) -> str:
"""Plain text of the assistant reply, with thinking/tool blocks dropped."""
chunks = [
getattr(block, "text", "") or ""
for block in getattr(self.output_message, "content", ())
if getattr(block, "type", None) == "text"
]
return "\n\n".join(c for c in chunks if c)
def _text_of(content: object) -> str:
"""Flatten an Anthropic ``MessageParam.content`` field to a plain string.
Handles both shapes the SDK accepts: raw string, or a list of block
dicts (we pull ``text`` blocks only). Anything we don't recognize is
silently skipped — these helpers exist for naming files, not for
faithful content reconstruction.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for blk in content:
if not isinstance(blk, dict):
continue
# ``MessageParam.content`` is typed as a union of typed-dicts
# per Anthropic SDK; we only care about plain ``text`` blocks
# and look them up via ``Any`` to dodge the keyed-typed-dict
# variance gymnastics (``ty`` won't let an open dict alias a
# closed-keyed one).
d: Any = blk
if d.get("type") == "text":
parts.append(str(d.get("text", "")))
return "\n\n".join(p for p in parts if p)
return ""