129 lines
3.9 KiB
Python
129 lines
3.9 KiB
Python
"""Render Anthropic ``Message`` (and user turns) into markdown.
|
|
|
|
One-way: produces the human-facing artifact only. The parser strips
|
|
tool/thinking callouts separately when reshaping history for backend replay.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
from anthropic.types import Message, TextBlock, ThinkingBlock
|
|
|
|
if TYPE_CHECKING:
|
|
from anthropic.types import MessageParam
|
|
|
|
|
|
__all__ = [
|
|
"FENCE",
|
|
"USER_SCAFFOLD",
|
|
"append_to_body",
|
|
"render_assistant_message",
|
|
"render_user_text",
|
|
"summarize_tool_input",
|
|
]
|
|
|
|
|
|
USER_SCAFFOLD = "### User:\n\n"
|
|
|
|
|
|
FENCE = "````"
|
|
|
|
_TITLE_KEYS = ("description", "path", "file", "filename", "url", "command", "query")
|
|
|
|
|
|
def render_user_text(content: str) -> str:
|
|
r"""Render one user-spoken turn as ``### User:\n\n<text>``."""
|
|
return f"### User:\n\n{content.strip()}\n"
|
|
|
|
|
|
def render_assistant_text(text: str) -> str:
|
|
r"""Render a text-only assistant turn as ``### Assistant:\n\n<text>``."""
|
|
return f"### Assistant:\n\n{text.strip()}\n"
|
|
|
|
|
|
def render_assistant_message(message: Message, *, thinking_is_progress: bool) -> str:
|
|
"""Render an assistant ``Message`` into a turn block.
|
|
|
|
Tool-use blocks render to nothing. Progress thinking renders as text in
|
|
place and vanishes when empty; reasoning renders as a collapsed callout,
|
|
of which only the first between two texts reaches the file.
|
|
"""
|
|
parts: list[str] = ["### Assistant:", ""]
|
|
after_reasoning = False
|
|
for block in message.content:
|
|
if isinstance(block, ThinkingBlock) and not thinking_is_progress:
|
|
if not after_reasoning:
|
|
parts.extend((*_render_reasoning(block.thinking or ""), ""))
|
|
after_reasoning = True
|
|
continue
|
|
text = _spoken(block)
|
|
if text:
|
|
parts.extend((text, ""))
|
|
after_reasoning = False
|
|
return "\n".join(parts).rstrip() + "\n"
|
|
|
|
|
|
def render_user_param(param: MessageParam) -> str:
|
|
"""Render a ``MessageParam`` user message into a ``### User:`` block.
|
|
|
|
Tool_result blocks in the content list are dropped silently.
|
|
"""
|
|
content = param.get("content", "")
|
|
if isinstance(content, str):
|
|
text = content
|
|
else:
|
|
chunks = [
|
|
str(blk.get("text", ""))
|
|
for blk in content
|
|
if isinstance(blk, dict) and blk.get("type") == "text"
|
|
]
|
|
text = "\n\n".join(chunks)
|
|
return render_user_text(text)
|
|
|
|
|
|
def append_to_body(existing: str, new_block: str) -> str:
|
|
"""Append ``new_block`` to ``existing`` with a decorative ``---`` separator.
|
|
|
|
The separator is visual only; the parser ignores it.
|
|
"""
|
|
head = existing.rstrip()
|
|
if head:
|
|
return f"{head}\n\n---\n\n{new_block}"
|
|
return new_block
|
|
|
|
|
|
def summarize_tool_input(name: str, tool_input: object) -> str:
|
|
"""Build the ``[!tool]- <summary>`` title, picking one salient input field."""
|
|
if not isinstance(tool_input, dict):
|
|
return name
|
|
d = cast("dict[str, Any]", tool_input)
|
|
for key in _TITLE_KEYS:
|
|
value = d.get(key)
|
|
if isinstance(value, str) and value:
|
|
short = value if len(value) <= 60 else value[:57] + "..."
|
|
return f"{name} · {short}"
|
|
return name
|
|
|
|
|
|
def _render_reasoning(text: str) -> tuple[str, ...]:
|
|
lines = text.strip().splitlines() or [""]
|
|
return ("> [!thinking]-", *(f"> {line}" if line else ">" for line in lines))
|
|
|
|
|
|
def _spoken(block: object) -> str:
|
|
if isinstance(block, TextBlock):
|
|
return (block.text or "").strip()
|
|
if isinstance(block, ThinkingBlock):
|
|
return (block.thinking or "").strip()
|
|
return ""
|
|
|
|
|
|
def adaptive_fence(content: str) -> str:
|
|
"""Return a backtick fence longer than any backtick run in ``content``."""
|
|
longest = 0
|
|
for match in re.finditer(r"`+", content):
|
|
longest = max(longest, len(match.group(0)))
|
|
return "`" * max(3, longest + 1)
|