txcript/transcript.rs
1//! The generic [`Transcript<H>`] and the three traits that act on it:
2//! [`Harness`] (what representation a transcript is in), [`Codec`] (mapping a
3//! native representation to and from [`Common`]), and [`Store`] (procuring and
4//! persisting native transcripts against a real backend).
5
6use std::collections::HashMap;
7use std::fmt;
8use std::ops::Range;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13use crate::common::{Message, Meta};
14use crate::error::Result;
15
16/// A transcript in some representation `H`.
17///
18/// `H` selects the body type: [`Common`] holds `Vec<Message>`, the canonical
19/// model; a harness marker holds that harness's faithful native records. `meta`
20/// is always the cross-harness [`Meta`]; harness-specific header detail lives
21/// inside `body`.
22pub struct Transcript<H: Harness = Common> {
23 pub meta: Meta,
24 pub body: H::Body,
25}
26
27impl<H: Harness> Transcript<H> {
28 pub fn new(meta: Meta, body: H::Body) -> Self {
29 Self { meta, body }
30 }
31}
32
33// Hand-written because deriving would wrongly demand `H: Clone`/`Debug`/`Eq`;
34// the bounds belong on the associated `Body`, not the marker `H`.
35impl<H: Harness> Clone for Transcript<H>
36where
37 H::Body: Clone,
38{
39 fn clone(&self) -> Self {
40 Self {
41 meta: self.meta.clone(),
42 body: self.body.clone(),
43 }
44 }
45}
46
47impl<H: Harness> fmt::Debug for Transcript<H>
48where
49 H::Body: fmt::Debug,
50{
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.debug_struct("Transcript")
53 .field("harness", &H::NAME)
54 .field("meta", &self.meta)
55 .field("body", &self.body)
56 .finish()
57 }
58}
59
60impl<H: Harness> PartialEq for Transcript<H>
61where
62 H::Body: PartialEq,
63{
64 fn eq(&self, other: &Self) -> bool {
65 self.meta == other.meta && self.body == other.body
66 }
67}
68
69/// A transcript representation. Implemented by [`Common`] and by each harness
70/// marker. The marker is a zero-size type; the representation is its `Body`.
71pub trait Harness {
72 /// Stable lowercase identifier, e.g. `"common"`, `"claude_code"`, `"codex"`.
73 const NAME: &'static str;
74
75 /// The body representation for this harness. `Common::Body = Vec<Message>`;
76 /// a harness's `Body` is its faithful native record set.
77 type Body;
78}
79
80/// The canonical hub representation. Every cross-harness conversion routes
81/// through `Transcript<Common>`.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct Common;
84
85impl Harness for Common {
86 const NAME: &'static str = "common";
87 type Body = Vec<Message>;
88}
89
90/// A half-open range of message indices: the primitive for pointing at part
91/// of a session. Owned and serializable, so it crosses process and wire
92/// boundaries (search results, CLI arguments, MCP responses); resolution
93/// against a loaded transcript is [`Transcript::fragment`].
94///
95/// Indices are positions in the parsed snapshot the span was minted against.
96/// They stay valid as a live session appends; they are not stable across
97/// cross-harness conversion.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct Span(pub Range<usize>);
100
101impl Transcript<Common> {
102 /// Resolve a [`Span`] to its messages, borrowing from this transcript.
103 /// `None` when the span reaches past the end of the session.
104 #[must_use]
105 pub fn fragment(&self, span: &Span) -> Option<&[Message]> {
106 self.body.get(span.0.clone())
107 }
108}
109
110/// Maps a harness's native representation to and from [`Common`].
111///
112/// `to_common` may *canonicalize* representation but must not *discard*
113/// detail — anything a same-harness round trip needs is preserved in
114/// [`Common`]'s typed fields. The `to_common`→`from_common` guarantee is
115/// semantic equality, not byte equality; byte-exactness lives at the
116/// native ↔ disk boundary in [`Store`].
117pub trait Codec: Harness + Sized {
118 /// # Errors
119 /// When the native records are malformed beyond the raw-fallback layer.
120 fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>>;
121 /// # Errors
122 /// When this harness cannot represent the transcript.
123 fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>>;
124}
125
126impl Codec for Common {
127 fn to_common(transcript: &Transcript<Common>) -> Result<Transcript<Common>> {
128 Ok(transcript.clone())
129 }
130 fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Common>> {
131 Ok(transcript.clone())
132 }
133}
134
135/// Convert a transcript from one harness to another through the [`Common`] hub.
136///
137/// ```ignore
138/// let codex_session = convert::<ClaudeCode, Codex>(&claude_session)?;
139/// ```
140///
141/// # Errors
142/// When `A` cannot parse its records or `B` cannot represent the transcript.
143pub fn convert<A, B>(transcript: &Transcript<A>) -> Result<Transcript<B>>
144where
145 A: Codec,
146 B: Codec,
147{
148 B::from_common(&A::to_common(transcript)?)
149}
150
151/// Parsing and rendering a harness's native session *text*, free of any
152/// filesystem or database. [`Store`] layers location on top of it; the WASM
153/// bindings use it directly.
154pub trait TextCodec: Harness + Sized {
155 /// Parse native session text into a transcript. `meta.id` may be empty when
156 /// the text carries no internal id; a [`Store`] fills it from the filename.
157 ///
158 /// # Errors
159 /// When the text is not this harness's session format.
160 fn from_text(text: &str) -> Result<Transcript<Self>>;
161
162 /// Render a transcript back to native session text.
163 ///
164 /// # Errors
165 /// When the records cannot be serialized.
166 fn to_text(transcript: &Transcript<Self>) -> Result<String>;
167}
168
169/// Reading and writing native transcripts against a real backend (a session
170/// directory, a `SQLite` database, an `import` subprocess).
171pub trait Store {
172 /// The harness this store reads and writes.
173 type H: Harness;
174 /// A locator for one transcript at rest: a file path, a database id, a slug.
175 type Ref;
176
177 /// Cheap metadata scan — no full message parsing.
178 ///
179 /// # Errors
180 /// When the backend itself fails; a missing root is `Ok(vec![])`.
181 fn discover(&self) -> Result<Vec<Discovered<Self::Ref>>>;
182
183 /// Load and parse one transcript into its faithful native representation.
184 ///
185 /// # Errors
186 /// When the reference doesn't exist or its content doesn't parse.
187 fn load(&self, reference: &Self::Ref) -> Result<Transcript<Self::H>>;
188
189 /// Persist a native transcript so the harness can resume it.
190 ///
191 /// # Errors
192 /// When the backend rejects the write.
193 fn save(&self, transcript: &Transcript<Self::H>) -> Result<Saved<Self::Ref>>;
194
195 /// Remove one transcript from the backend so the harness no longer lists
196 /// or resumes it. File-backed stores remove the session file or directory;
197 /// `OpenCode` archives the session in place.
198 ///
199 /// # Errors
200 /// When the reference doesn't exist or the backend rejects the removal.
201 fn delete(&self, reference: &Self::Ref) -> Result<()>;
202
203 /// Per-reference change cursors, for callers that cache parsed transcripts.
204 /// Default: no fingerprints, forcing a re-parse. Backends with a cheap
205 /// change signal (file mtime, a `MAX(updated)` query) should override.
206 ///
207 /// # Errors
208 /// When the backend itself fails; per-reference failures are empty strings.
209 fn fingerprints(&self, _refs: &[Self::Ref]) -> Result<HashMap<String, String>> {
210 Ok(HashMap::new())
211 }
212}
213
214/// A transcript found by [`Store::discover`]: its metadata and how to load it.
215#[derive(Debug, Clone)]
216pub struct Discovered<R> {
217 pub meta: Meta,
218 pub reference: R,
219}
220
221/// The outcome of [`Store::save`]: the id the harness will resume by, and where
222/// it landed.
223#[derive(Debug, Clone)]
224pub struct Saved<R> {
225 pub id: String,
226 pub reference: R,
227}
228
229/// Runtime tag for the harnesses this crate implements — string-keyed
230/// dispatch, where the type-level [`Harness`] markers select a
231/// [`Body`](Harness::Body).
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum HarnessId {
235 ClaudeCode,
236 Codex,
237 OpenCode,
238 Pi,
239 Campfire,
240 Cursor,
241 CursorDesktop,
242 Grok,
243 Amp,
244 Antigravity,
245}
246
247impl HarnessId {
248 pub const ALL: [HarnessId; 10] = [
249 HarnessId::ClaudeCode,
250 HarnessId::Codex,
251 HarnessId::OpenCode,
252 HarnessId::Pi,
253 HarnessId::Campfire,
254 HarnessId::Cursor,
255 HarnessId::CursorDesktop,
256 HarnessId::Grok,
257 HarnessId::Amp,
258 HarnessId::Antigravity,
259 ];
260
261 /// The stable lowercase name, matching the corresponding [`Harness::NAME`].
262 #[must_use]
263 pub const fn as_str(self) -> &'static str {
264 match self {
265 HarnessId::ClaudeCode => "claude_code",
266 HarnessId::Codex => "codex",
267 HarnessId::OpenCode => "opencode",
268 HarnessId::Pi => "pi",
269 HarnessId::Campfire => "campfire",
270 HarnessId::Cursor => "cursor",
271 HarnessId::CursorDesktop => "cursor_desktop",
272 HarnessId::Grok => "grok",
273 HarnessId::Amp => "amp",
274 HarnessId::Antigravity => "antigravity",
275 }
276 }
277}
278
279impl fmt::Display for HarnessId {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 f.write_str(self.as_str())
282 }
283}
284
285impl FromStr for HarnessId {
286 type Err = crate::error::Error;
287
288 fn from_str(s: &str) -> Result<Self> {
289 // Accept a few friendly aliases alongside the canonical names.
290 match s.trim().to_ascii_lowercase().as_str() {
291 "claude" | "claude_code" | "claude-code" | "claudecode" => Ok(HarnessId::ClaudeCode),
292 "codex" => Ok(HarnessId::Codex),
293 "opencode" | "open_code" | "open-code" => Ok(HarnessId::OpenCode),
294 "pi" => Ok(HarnessId::Pi),
295 "campfire" => Ok(HarnessId::Campfire),
296 "cursor" | "cursor_cli" | "cursor-cli" | "cursorcli" => Ok(HarnessId::Cursor),
297 "cursor_desktop" | "cursor-desktop" | "cursordesktop" | "cursor_ide" | "cursor-ide" => {
298 Ok(HarnessId::CursorDesktop)
299 }
300 "grok" | "grok_cli" | "grok-cli" | "grokcli" | "grok_build" | "grok-build" => {
301 Ok(HarnessId::Grok)
302 }
303 "amp" | "ampcode" | "amp_code" | "amp-code" => Ok(HarnessId::Amp),
304 "antigravity" | "agy" | "antigravity_cli" | "antigravity-cli" | "anti-gravity" => {
305 Ok(HarnessId::Antigravity)
306 }
307 other => Err(crate::error::Error::UnknownHarness(other.to_string())),
308 }
309 }
310}