feat: v0.4.0 — rich content support with typed blocks and loss visibility

Extracts per-message content into a typed `blocks` list (text, code,
thinking, tool_use, tool_result, image_placeholder, file_placeholder,
unknown) and renders them at exporter write time. Voice transcripts,
Custom Instructions, and image references now appear in exports
instead of being silently dropped.

Foundation:
- src/blocks.py: pure block constructors, _safe_fence (fence-corruption
  defense, verified live in Joplin), _blockquote_prefix, render
- src/loss_report.py: per-run tally surfaced as INFO summary at end of
  export so silently-dropped data becomes visible

Providers:
- ChatGPT: dispatch on content_type produces typed blocks; voice shapes
  (audio_transcription, audio_asset_pointer, real_time_user_audio_video_
  asset_pointer) locked from live DevTools capture; Custom Instructions
  bug fix (parts-vs-direct-fields); role filter lifted; hidden-context
  marker driven by is_visually_hidden_from_conversation flag
- Claude: defensive dispatch for text/thinking/tool_use/tool_result/image
  with recursive nested-block flattening; untested against real rich-
  content data — fix-forward in v0.4.1

Exporter:
- Markdown renders from blocks at write time via render_blocks_to_markdown;
  backward-compat fallback to content for any pre-v0.4.0 cached data

Tests:
- 27 new tests across providers, exporters, CLI; fixtures rebuilt with
  real-shape ChatGPT voice + Custom Instructions cases
- 181/181 pass

Behavior changes (intentional):
- JSON output omits content; consumers should read blocks
- Per-conversation message counts increase (Custom Instructions, image-
  only, tool-only messages now appear)
- Existing exports not auto-re-rendered; users wanting fresh output run
  cache --clear then export

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
JesseMarkowitz
2026-05-04 23:17:18 -04:00
co-authored by Claude Opus 4.7
parent 4798edcea7
commit 473d02f71a
16 changed files with 1786 additions and 232 deletions
+11 -3
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from src.blocks import render_blocks_to_markdown
from src.utils import build_export_path, generate_filename
logger = logging.getLogger(__name__)
@@ -125,10 +126,17 @@ class MarkdownExporter:
# Messages
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
blocks = msg.get("blocks") or []
timestamp = msg.get("timestamp")
if not content or not content.strip():
# Prefer rendering from blocks (v0.4.0+). Backward-compat fallback:
# if blocks is missing/empty AND content exists, render content as-is.
if blocks:
body = render_blocks_to_markdown(blocks)
else:
body = msg.get("content", "") or ""
if not body or not body.strip():
logger.warning(
"[markdown] Skipping empty/whitespace message in conversation %s",
conv_id[:8],
@@ -143,7 +151,7 @@ class MarkdownExporter:
else:
lines.append("")
lines.append(content)
lines.append(body)
lines.append("")
lines.append("---")
lines.append("")