supercode-harness 0.5.45

The optional native Volter Harness agent and tool harness
Documentation
//! P4e (COMPOSABLE-HARNESS-DESIGN.md §1.6/§3.1 `core.session.export_format`,
//! catalog:283 "transcript export for humans"): a READ-ONLY rendering of a
//! [`crate::Session`]'s conversation into text a human reads directly
//! (terminal/file/clipboard) or opens in a browser — CC's `/export`+`/copy`,
//! CX's Ctrl+O copy-last. This is core, not gated by the `session.share`
//! module (§1.6: "export-to-human is universal while *share links* … are
//! the OC+PI-only part `session.share` actually narrows to").
//!
//! Deliberately distinct from [`crate::reduce::export_session`], which
//! translates a session losslessly BETWEEN harness wire formats (priority-1
//! "translate" — machine-to-machine, round-trippable, JSONL). This module
//! goes the other direction: session (any harness, already loaded) to a
//! human-readable rendering (JSONL in, prose/markup out — deliberately NOT
//! round-trippable, and never claims to be). Per §1.13, the session DATA
//! itself stays typed/lossless in the sidecar; a render is a projection a
//! human reads, never a channel anything is reconstructed from — so
//! [`render_transcript`] takes `&Session` (never mutates it) and returns an
//! owned `String`.

use supercode_interchange::session::Session;
use supercode_interchange::{ChatMessage, Role};

/// `core.session.export_format` (§3.1): which rendering
/// [`render_transcript`] produces.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HumanExportFormat {
    /// Plain text, one paragraph per message, role-labeled headers. The
    /// default.
    #[default]
    Text,
    /// A minimal, dependency-free (no template engine) standalone HTML
    /// document — safe to open directly in a browser.
    Html,
}

impl HumanExportFormat {
    /// Parse the `"text"` / `"html"` config strings (§3.1
    /// `core.session.export_format`). Unrecognized input is `None` — the
    /// caller decides the fail-safe fallback (mirrors
    /// `SteeringMode::parse`'s contract).
    pub fn parse(s: &str) -> Option<HumanExportFormat> {
        match s {
            "text" => Some(HumanExportFormat::Text),
            "html" => Some(HumanExportFormat::Html),
            _ => None,
        }
    }
}

/// Render `session`'s conversation (`session.messages`, in order) for a
/// human, in `format`. Pure/read-only: `session` is untouched, and calling
/// this twice on the same session is idempotent (same output both times).
/// System messages are included — they're part of the honest record of
/// what happened (e.g. compaction markers, `context_injections` blocks).
pub fn render_transcript(session: &Session, format: HumanExportFormat) -> String {
    render_messages(
        &session.messages,
        session.meta.session_id.as_deref(),
        session.meta.model.as_deref(),
        format,
    )
}

/// The lower-level entry point [`render_transcript`] delegates to: render a
/// bare `messages` slice (no [`Session`] wrapper required) — for a caller
/// (e.g. the CLI's `sessions export`) that already has the parsed
/// [`ChatMessage`]s and a session name/model but not a full [`Session`]
/// (which is `#[non_exhaustive]` and cannot be constructed outside this
/// crate). Same read-only/idempotent contract as [`render_transcript`].
pub fn render_messages(
    messages: &[ChatMessage],
    session_id: Option<&str>,
    model: Option<&str>,
    format: HumanExportFormat,
) -> String {
    match format {
        HumanExportFormat::Text => render_text(messages, session_id, model),
        HumanExportFormat::Html => render_html(messages, session_id, model),
    }
}

fn role_label(role: Role) -> &'static str {
    match role {
        Role::System => "System",
        Role::User => "User",
        Role::Assistant => "Assistant",
        Role::Tool => "Tool",
    }
}

/// The body text a single message contributes to a render: its plain
/// `content` if set, else a placeholder describing any tool calls / an
/// empty turn — every message contributes SOME visible line, so a reader
/// never sees a silently-skipped turn.
fn message_body(msg: &ChatMessage) -> String {
    let mut parts = Vec::new();
    if let Some(content) = &msg.content {
        if !content.is_empty() {
            parts.push(content.clone());
        }
    }
    if let Some(calls) = &msg.tool_calls {
        for call in calls {
            parts.push(format!(
                "[tool call: {}({})]",
                call.function.name, call.function.arguments
            ));
        }
    }
    if parts.is_empty() {
        parts.push("(empty)".to_string());
    }
    parts.join("\n")
}

fn render_text(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
    let mut out = String::new();
    if let Some(id) = session_id {
        out.push_str(&format!("Session: {id}\n"));
    }
    if let Some(model) = model {
        out.push_str(&format!("Model: {model}\n"));
    }
    if !out.is_empty() {
        out.push('\n');
    }
    for (i, msg) in messages.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        out.push_str(&format!("## {}\n", role_label(msg.role)));
        out.push_str(&message_body(msg));
        out.push('\n');
    }
    out
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn render_html(messages: &[ChatMessage], session_id: Option<&str>, model: Option<&str>) -> String {
    let mut out = String::new();
    out.push_str("<!doctype html>\n<html><head><meta charset=\"utf-8\">\n");
    out.push_str("<title>Volter Harness session export</title>\n");
    out.push_str(
        "<style>body{font-family:monospace;max-width:60rem;margin:2rem auto;padding:0 1rem}\
         .msg{border-left:3px solid #ccc;margin:1rem 0;padding:0 1rem;white-space:pre-wrap}\
         .role{font-weight:bold}</style>\n",
    );
    out.push_str("</head><body>\n");
    if let Some(id) = session_id {
        out.push_str(&format!("<p>Session: {}</p>\n", html_escape(id)));
    }
    if let Some(model) = model {
        out.push_str(&format!("<p>Model: {}</p>\n", html_escape(model)));
    }
    for msg in messages {
        out.push_str("<div class=\"msg\"><div class=\"role\">");
        out.push_str(role_label(msg.role));
        out.push_str("</div><div class=\"body\">");
        out.push_str(&html_escape(&message_body(msg)));
        out.push_str("</div></div>\n");
    }
    out.push_str("</body></html>\n");
    out
}