Skip to main content

supercode_harness/
human_export.rs

1//! P4e (COMPOSABLE-HARNESS-DESIGN.md §1.6/§3.1 `core.session.export_format`,
2//! catalog:283 "transcript export for humans"): a READ-ONLY rendering of a
3//! [`crate::Session`]'s conversation into text a human reads directly
4//! (terminal/file/clipboard) or opens in a browser — CC's `/export`+`/copy`,
5//! CX's Ctrl+O copy-last. This is core, not gated by the `session.share`
6//! module (§1.6: "export-to-human is universal while *share links* … are
7//! the OC+PI-only part `session.share` actually narrows to").
8//!
9//! Deliberately distinct from [`crate::reduce::export_session`], which
10//! translates a session losslessly BETWEEN harness wire formats (priority-1
11//! "translate" — machine-to-machine, round-trippable, JSONL). This module
12//! goes the other direction: session (any harness, already loaded) to a
13//! human-readable rendering (JSONL in, prose/markup out — deliberately NOT
14//! round-trippable, and never claims to be). Per §1.13, the session DATA
15//! itself stays typed/lossless in the sidecar; a render is a projection a
16//! human reads, never a channel anything is reconstructed from — so
17//! [`render_transcript`] takes `&Session` (never mutates it) and returns an
18//! owned `String`.
19
20use supercode_interchange::session::Session;
21use supercode_interchange::{ChatMessage, Role};
22
23/// `core.session.export_format` (§3.1): which rendering
24/// [`render_transcript`] produces.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum HumanExportFormat {
27    /// Plain text, one paragraph per message, role-labeled headers. The
28    /// default.
29    #[default]
30    Text,
31    /// A minimal, dependency-free (no template engine) standalone HTML
32    /// document — safe to open directly in a browser.
33    Html,
34}
35
36impl HumanExportFormat {
37    /// Parse the `"text"` / `"html"` config strings (§3.1
38    /// `core.session.export_format`). Unrecognized input is `None` — the
39    /// caller decides the fail-safe fallback (mirrors
40    /// `SteeringMode::parse`'s contract).
41    pub fn parse(s: &str) -> Option<HumanExportFormat> {
42        match s {
43            "text" => Some(HumanExportFormat::Text),
44            "html" => Some(HumanExportFormat::Html),
45            _ => None,
46        }
47    }
48}
49
50/// Render `session`'s conversation (`session.messages`, in order) for a
51/// human, in `format`. Pure/read-only: `session` is untouched, and calling
52/// this twice on the same session is idempotent (same output both times).
53/// System messages are included — they're part of the honest record of
54/// what happened (e.g. compaction markers, `context_injections` blocks).
55pub fn render_transcript(session: &Session, format: HumanExportFormat) -> String {
56    render_messages(
57        &session.messages,
58        session.meta.session_id.as_deref(),
59        session.meta.model.as_deref(),
60        format,
61    )
62}
63
64/// The lower-level entry point [`render_transcript`] delegates to: render a
65/// bare `messages` slice (no [`Session`] wrapper required) — for a caller
66/// (e.g. the CLI's `sessions export`) that already has the parsed
67/// [`ChatMessage`]s and a session name/model but not a full [`Session`]
68/// (which is `#[non_exhaustive]` and cannot be constructed outside this
69/// crate). Same read-only/idempotent contract as [`render_transcript`].
70pub fn render_messages(
71    messages: &[ChatMessage],
72    session_id: Option<&str>,
73    model: Option<&str>,
74    format: HumanExportFormat,
75) -> String {
76    match format {
77        HumanExportFormat::Text => render_text(messages, session_id, model),
78        HumanExportFormat::Html => render_html(messages, session_id, model),
79    }
80}
81
82fn role_label(role: Role) -> &'static str {
83    match role {
84        Role::System => "System",
85        Role::User => "User",
86        Role::Assistant => "Assistant",
87        Role::Tool => "Tool",
88    }
89}
90
91/// The body text a single message contributes to a render: its plain
92/// `content` if set, else a placeholder describing any tool calls / an
93/// empty turn — every message contributes SOME visible line, so a reader
94/// never sees a silently-skipped turn.
95fn message_body(msg: &ChatMessage) -> String {
96    let mut parts = Vec::new();
97    if let Some(content) = &msg.content {
98        if !content.is_empty() {
99            parts.push(content.clone());
100        }
101    }
102    if let Some(calls) = &msg.tool_calls {
103        for call in calls {
104            parts.push(format!(
105                "[tool call: {}({})]",
106                call.function.name, call.function.arguments
107            ));
108        }
109    }
110    if parts.is_empty() {
111        parts.push("(empty)".to_string());
112    }
113    parts.join("\n")
114}
115
116fn render_text(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
117    let mut out = String::new();
118    if let Some(id) = session_id {
119        out.push_str(&format!("Session: {id}\n"));
120    }
121    if let Some(model) = model {
122        out.push_str(&format!("Model: {model}\n"));
123    }
124    if !out.is_empty() {
125        out.push('\n');
126    }
127    for (i, msg) in messages.iter().enumerate() {
128        if i > 0 {
129            out.push('\n');
130        }
131        out.push_str(&format!("## {}\n", role_label(msg.role)));
132        out.push_str(&message_body(msg));
133        out.push('\n');
134    }
135    out
136}
137
138fn html_escape(s: &str) -> String {
139    s.replace('&', "&amp;")
140        .replace('<', "&lt;")
141        .replace('>', "&gt;")
142        .replace('"', "&quot;")
143}
144
145fn render_html(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
146    let mut out = String::new();
147    out.push_str("<!doctype html>\n<html><head><meta charset=\"utf-8\">\n");
148    out.push_str("<title>Volter Harness session export</title>\n");
149    out.push_str(
150        "<style>body{font-family:monospace;max-width:60rem;margin:2rem auto;padding:0 1rem}\
151         .msg{border-left:3px solid #ccc;margin:1rem 0;padding:0 1rem;white-space:pre-wrap}\
152         .role{font-weight:bold}</style>\n",
153    );
154    out.push_str("</head><body>\n");
155    if let Some(id) = session_id {
156        out.push_str(&format!("<p>Session: {}</p>\n", html_escape(id)));
157    }
158    if let Some(model) = model {
159        out.push_str(&format!("<p>Model: {}</p>\n", html_escape(model)));
160    }
161    for msg in messages {
162        out.push_str("<div class=\"msg\"><div class=\"role\">");
163        out.push_str(role_label(msg.role));
164        out.push_str("</div><div class=\"body\">");
165        out.push_str(&html_escape(&message_body(msg)));
166        out.push_str("</div></div>\n");
167    }
168    out.push_str("</body></html>\n");
169    out
170}