182 lines
6.2 KiB
Python
182 lines
6.2 KiB
Python
"""Render Anthropic ``Message`` (and individual user turns) into markdown.
|
|
|
|
The renderer is one-way: it produces the human-facing artifact in the
|
|
vault. The parser strips tool/thinking callouts when reshaping the file
|
|
for backend replay — so what we write here is purely for the human
|
|
reader (and for the cross-frontend logger, which materializes turns
|
|
from other frontends).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
from anthropic.types import MessageParam
|
|
|
|
|
|
__all__ = [
|
|
"FENCE",
|
|
"USER_SCAFFOLD",
|
|
"append_to_body",
|
|
"render_assistant_message",
|
|
"render_user_text",
|
|
"summarize_tool_input",
|
|
]
|
|
|
|
|
|
# Empty ``### User:`` block appended after each assistant reply so the
|
|
# human has an obvious place to type the next turn. Parser drops empty
|
|
# user blocks, so this doesn't re-trigger dispatch on its own.
|
|
USER_SCAFFOLD = "### User:\n"
|
|
|
|
|
|
# Default 4-backtick fence so tool results that contain literal ```` ``` ````
|
|
# don't collide. JSON inputs use 3 backticks because they almost never
|
|
# contain ``` and we get language syntax highlighting in Obsidian for free.
|
|
FENCE = "````"
|
|
|
|
# Used when a tool input mentions a path/file we can dangle in the
|
|
# callout title — purely cosmetic.
|
|
_PATH_KEYS = ("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_message(message: Message) -> str:
|
|
"""Render an assistant ``Message`` (with content blocks) into a turn block.
|
|
|
|
Blocks render in their original order:
|
|
|
|
* ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout
|
|
* ``TextBlock`` → plain text (the spoken answer)
|
|
* ``ToolUseBlock`` → ``> [!tool]- <name>`` callout with the ``input``
|
|
JSON quoted inside. Tool *results* are not persisted — see
|
|
module docstring on ``parser.py`` for why.
|
|
|
|
Blank lines separate adjacent blocks; trailing newline guarantees
|
|
the next ``---`` / ``### User:`` marker lands on its own line.
|
|
"""
|
|
parts: list[str] = ["### Assistant:", ""]
|
|
for block in message.content:
|
|
parts.extend(_render_block(block))
|
|
parts.append("")
|
|
return "\n".join(parts).rstrip() + "\n"
|
|
|
|
|
|
def render_user_param(param: MessageParam) -> str:
|
|
"""Render a ``MessageParam`` user message into a ``### User:`` block.
|
|
|
|
Used by the cross-frontend logger when materializing turns from
|
|
other frontends. Tool_result blocks in the content list are dropped
|
|
silently — the markdown view doesn't track them (see ``parser.py``).
|
|
"""
|
|
content = param.get("content", "")
|
|
if isinstance(content, str):
|
|
text = content
|
|
else:
|
|
# The Anthropic SDK types ``content`` as a union of typed-dict
|
|
# *Param classes plus pydantic block models — both shapes appear
|
|
# in practice (raw incoming JSON yields dicts, SDK-built params
|
|
# yield BaseModels). Treat each entry as a dict-like and pull
|
|
# ``text`` opportunistically.
|
|
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 HR separator.
|
|
|
|
Preserves the original body verbatim (whitespace, callouts, any
|
|
formatting the human added). The HR is purely visual: 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 string.
|
|
|
|
Tries to pick a single salient field (``path``, ``command``, etc.)
|
|
from the input dict so the collapsed callout shows something
|
|
meaningful in Obsidian. Falls back to just the tool name.
|
|
"""
|
|
if not isinstance(tool_input, dict):
|
|
return name
|
|
# Anthropic ``ToolUseBlock.input`` is typed as ``object`` — the
|
|
# SDK's runtime value is always a JSON dict (str→Any), so a local
|
|
# cast keeps the rest of the function readable without sprinkling
|
|
# per-line type narrowing on every ``.get`` call.
|
|
d = cast("dict[str, Any]", tool_input)
|
|
for key in _PATH_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
|
|
|
|
|
|
# ---- internals ---------------------------------------------------------
|
|
|
|
|
|
def _render_block(block: object) -> Iterable[str]:
|
|
if isinstance(block, TextBlock):
|
|
text = (block.text or "").strip()
|
|
if text:
|
|
yield text
|
|
return
|
|
if isinstance(block, ThinkingBlock):
|
|
yield from _render_thinking(block.thinking or "")
|
|
return
|
|
if isinstance(block, ToolUseBlock):
|
|
yield from _render_tool_use(block)
|
|
return
|
|
# Unknown block type — skip silently rather than corrupting the file.
|
|
|
|
|
|
def _render_thinking(text: str) -> Iterable[str]:
|
|
yield "> [!thinking]-"
|
|
for line in text.strip().splitlines() or [""]:
|
|
yield f"> {line}" if line else ">"
|
|
|
|
|
|
def _render_tool_use(block: ToolUseBlock) -> Iterable[str]:
|
|
title = summarize_tool_input(block.name, block.input)
|
|
yield f"> [!tool]- {title}"
|
|
yield "> **input:**"
|
|
yield "> ```json"
|
|
pretty = json.dumps(block.input, indent=2, ensure_ascii=False, sort_keys=True)
|
|
for line in pretty.splitlines():
|
|
yield f"> {line}" if line else ">"
|
|
yield "> ```"
|
|
|
|
|
|
def adaptive_fence(content: str) -> str:
|
|
"""Return a backtick fence at least one longer than the longest run in ``content``.
|
|
|
|
Currently unused (tool *results* aren't persisted yet) — kept here
|
|
so when result capture lands the rendering side already has the
|
|
primitive.
|
|
"""
|
|
longest = 0
|
|
for match in re.finditer(r"`+", content):
|
|
longest = max(longest, len(match.group(0)))
|
|
return "`" * max(3, longest + 1)
|