Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/formats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,23 @@ than none.
| [pi.md](pi.md) | pi | `src/harness/pi.rs` |
| [campfire.md](campfire.md) | Campfire (embeds pi) | `src/harness/campfire.rs` |
| [simple.md](simple.md) | Simple (txcript's own interchange format) | `src/harness/simple.rs` |

## Conversion fidelity

What converting *into* each harness loses, measured by `txcript::fidelity` on one synthetic session that has every kind of block: signed and unsigned thinking, a Bash call with a description, an edit, an MCP call with a structured result, an error result, a slash command, an image, token usage, stop reasons, per-message models, a title, a git branch. Each cell names the categories that were lost or changed (`ok`: nothing). Items the target only made up, and message timestamps, are not listed.

| Target | Content | Accounting | Metadata |
| --- | --- | --- | --- |
| claude_code | ok | changed: stop_reason | ok |
| codex | lost: tool_args, is_error; changed: tool_name | lost: thinking_signature, usage, stop_reason | lost: title, model |
| opencode | lost: tool_call, tool_result | lost: thinking_signature; changed: stop_reason | lost: git_branch |
| pi | lost: tool_call | lost: thinking_signature | lost: title, model, git_branch |
| campfire | lost: tool_call | lost: thinking_signature | lost: title, model, git_branch |
| cursor | lost: tool_call | lost: usage, stop_reason | lost: git_branch |
| cursor_desktop | lost: tool_call, tool_args, tool_result, image | lost: usage, stop_reason | lost: git_branch |
| grok | lost: tool_call | lost: thinking_signature, usage, stop_reason | ok |
| fx | lost: tool_call, tool_args | lost: usage, stop_reason | lost: git_branch |
| antigravity | lost: tool_call, tool_args, tool_result | lost: message_model | lost: model; changed: title |
| cowork | ok | changed: stop_reason | ok |

The `tool_call` and `tool_result` losses are the slash command, which several formats have no way to record; `tool_name` is it written as `_release`. Hermes, Amp, Simple, and `grok_bot` are measured too (see the golden table in `tests/integration/fidelity.rs`) but are not listed here: `continue` cannot write the first three, and `grok_bot` mints a live agent. The measurement is codec-level: it does not include what a store adds, such as artifact files. It is a regression oracle as well as documentation: `tests/integration/fidelity.rs` fails when the table drifts from the codecs, and prints the rows to paste.
77 changes: 77 additions & 0 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//! Text-level codec dispatch: parse a harness's native session text into the
//! canonical model, or render the canonical model back to native text, keyed
//! by a runtime [`HarnessId`].
//!
//! No filesystem, no `SQLite`, no subprocess: this is the codec layer alone
//! (see [`TextCodec`]), so it is available on every target, WASM included. The
//! WASM bindings and [`fidelity`](crate::fidelity) both route through it.

use crate::harness::{
amp, antigravity, campfire, chatgpt, claude_chat, claude_code, codex, cowork, cowork_remote,
cursor, cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, simple,
};
use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript};

/// Parse `harness`'s native session text into the canonical model.
///
/// # Errors
/// When the text is not that harness's session format.
pub fn parse_to_common(harness: HarnessId, text: &str) -> crate::Result<Transcript<Common>> {
fn go<H: TextCodec + Codec>(text: &str) -> crate::Result<Transcript<Common>> {
H::to_common(&H::from_text(text)?)
}
match harness {
HarnessId::ClaudeCode => go::<claude_code::ClaudeCode>(text),
HarnessId::ClaudeChat => go::<claude_chat::ClaudeChat>(text),
HarnessId::CoworkRemote => go::<cowork_remote::CoworkRemote>(text),
HarnessId::ChatGpt => go::<chatgpt::ChatGpt>(text),
HarnessId::Codex => go::<codex::Codex>(text),
HarnessId::OpenCode => go::<opencode::OpenCode>(text),
HarnessId::Pi => go::<pi::Pi>(text),
HarnessId::Campfire => go::<campfire::Campfire>(text),
HarnessId::Cursor => go::<cursor::Cursor>(text),
HarnessId::CursorDesktop => go::<cursor_desktop::CursorDesktop>(text),
HarnessId::Grok => go::<grok::Grok>(text),
HarnessId::GrokBot => go::<grok_bot::GrokBot>(text),
HarnessId::Fx => go::<fx::Fx>(text),
HarnessId::Hermes => go::<hermes::Hermes>(text),
HarnessId::Amp => go::<amp::Amp>(text),
HarnessId::Antigravity => go::<antigravity::Antigravity>(text),
HarnessId::Simple => go::<simple::Simple>(text),
HarnessId::Cowork => go::<cowork::Cowork>(text),
}
}

/// Render the canonical model as `harness`'s native session text.
///
/// # Errors
/// When the harness cannot represent the transcript (the live read-only
/// sources, for one).
pub fn render_from_common(
harness: HarnessId,
common: &Transcript<Common>,
) -> crate::Result<String> {
fn go<H: TextCodec + Codec>(common: &Transcript<Common>) -> crate::Result<String> {
H::to_text(&H::from_common(common)?)
}
match harness {
HarnessId::ClaudeCode => go::<claude_code::ClaudeCode>(common),
HarnessId::ClaudeChat => go::<claude_chat::ClaudeChat>(common),
HarnessId::CoworkRemote => go::<cowork_remote::CoworkRemote>(common),
HarnessId::ChatGpt => go::<chatgpt::ChatGpt>(common),
HarnessId::Codex => go::<codex::Codex>(common),
HarnessId::OpenCode => go::<opencode::OpenCode>(common),
HarnessId::Pi => go::<pi::Pi>(common),
HarnessId::Campfire => go::<campfire::Campfire>(common),
HarnessId::Cursor => go::<cursor::Cursor>(common),
HarnessId::CursorDesktop => go::<cursor_desktop::CursorDesktop>(common),
HarnessId::Grok => go::<grok::Grok>(common),
HarnessId::GrokBot => go::<grok_bot::GrokBot>(common),
HarnessId::Fx => go::<fx::Fx>(common),
HarnessId::Hermes => go::<hermes::Hermes>(common),
HarnessId::Amp => go::<amp::Amp>(common),
HarnessId::Antigravity => go::<antigravity::Antigravity>(common),
HarnessId::Simple => go::<simple::Simple>(common),
HarnessId::Cowork => go::<cowork::Cowork>(common),
}
}
Loading
Loading