Skip to main content

supercode_harness/
mail_transcript.rs

1//! What a session's transcript says about its mail: the lines its own user
2//! typed, and when each message reached the model.
3//!
4//! The mailbox records a message as SENT: filed for its session and handed to
5//! the session's door. Only the harness's transcript records it as DELIVERED:
6//! in the model's context. The two differ whenever the session is busy, since
7//! a message handed to a harness mid-turn waits in its queue until the running
8//! tool call returns. This module reads the transcript for both halves of that.
9//!
10//! A line a person types into a session reaches it through the harness's own
11//! composer, not through supercode, so no envelope is filed for it. Claude Code
12//! records each one:
13//!
14//! - typed at an idle prompt: a `user` entry marked `promptSource: typed`;
15//! - typed while the session was busy: a `queue-operation` `enqueue` (sent),
16//!   then either a `dequeue` and a `user` entry marked `promptSource: queued`,
17//!   or a `remove` (`absorbed_mid_turn`) and a `queued_command` attachment from
18//!   a human (delivered).
19//!
20//! [`file_typed_lines`] files each as a `user` envelope in the session's
21//! mailbox, so it has an id like every other message. The id is derived from
22//! the session's address, the moment the line was sent and its words, so it is
23//! the same before and after the line reaches the model, and every reader
24//! derives the same one. A line supercode itself typed into the session's pane
25//! (a Room member's or a voice turn) is filed already under its own id.
26//!
27//! Mail supercode filed reaches the model as its rendering,
28//! `<cross-session-message id="m-…"`, inside a prompt or a queued-command
29//! attachment; a Room or voice turn reaches it as a typed line with its words.
30//!
31//! Codex rollouts do not record who wrote a user message (a typed line and the
32//! injected AGENTS.md or environment context look alike), and other harnesses'
33//! records are not read here: their typed lines are not filed and their mail's
34//! delivery is unknown.
35
36use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
37use std::io::BufRead;
38use std::path::{Path, PathBuf};
39
40use serde::Serialize;
41
42use crate::mailbox::{Envelope, MailAddress, MailKind, Mailbox, ReplyVia, StoredEnvelope};
43use crate::HarnessHomes;
44
45/// Prefix of the id of a line a session's user typed.
46pub const TYPED_ID_PREFIX: &str = "u-";
47
48/// Prefix of the id of a session's answer to its user.
49pub const ANSWER_ID_PREFIX: &str = "a-";
50
51/// The id of the answer the transcript entry `native_id` records in the
52/// session at `address`: `a-` and 24 hex digits.
53pub fn answer_id(address: &MailAddress, native_id: &str) -> String {
54    let hash = blake3::hash(format!("{address}\n{native_id}").as_bytes()).to_hex();
55    format!("{ANSWER_ID_PREFIX}{}", &hash[..24])
56}
57
58/// The mailbox of the person who uses the sessions on `machine`: where the
59/// lines they type come from and where the sessions' answers go.
60pub fn user_address(machine: &str) -> std::io::Result<MailAddress> {
61    MailAddress::new(machine.to_string(), "operator", "user")
62        .map_err(|error| std::io::Error::other(error.0))
63}
64
65/// The id of the line with `text` that the user of the session at `address`
66/// sent at `sent_at_ms`: `u-` and 24 hex digits.
67pub fn typed_line_id(address: &MailAddress, sent_at_ms: u64, text: &str) -> String {
68    let hash = blake3::hash(format!("{address}\n{sent_at_ms}\n{text}").as_bytes()).to_hex();
69    format!("{TYPED_ID_PREFIX}{}", &hash[..24])
70}
71
72/// One line the session's user typed, as its transcript records it.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct TypedLine {
75    /// When the user sent it, in epoch milliseconds.
76    pub sent_at_ms: u64,
77    /// When it reached the model.
78    pub delivered_at_ms: Option<u64>,
79    /// The words, as typed.
80    pub text: String,
81}
82
83/// One answer the session gave its user: a turn's final message.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Answer {
86    /// The transcript entry's own id.
87    pub native_id: String,
88    /// When it was written, in epoch milliseconds.
89    pub at_ms: u64,
90    /// Its words.
91    pub text: String,
92}
93
94/// What one transcript says about a session's mail.
95#[derive(Debug, Clone, Default)]
96pub struct TranscriptMail {
97    /// The lines the session's user typed, in the order they were sent.
98    pub typed: Vec<TypedLine>,
99    /// The session's answers to its user, in order.
100    pub answers: Vec<Answer>,
101    /// Each filed message the model has seen, by id: when it first did.
102    pub delivered: HashMap<String, u64>,
103}
104
105/// Where a message stands.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
107#[serde(tag = "state", rename_all = "snake_case")]
108pub enum Delivery {
109    /// In the model's context since this moment (epoch milliseconds).
110    Delivered {
111        /// When it first was.
112        at_ms: u64,
113    },
114    /// Sent to the session, not yet in its model's context.
115    Sent,
116    /// The session's transcript cannot be read here, so whether it reached
117    /// the model is not known.
118    Unknown,
119}
120
121impl Delivery {
122    /// How an inbox listing says it.
123    pub fn describe(&self) -> String {
124        match self {
125            Self::Delivered { at_ms } => {
126                supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
127            }
128            Self::Sent => "not yet".to_string(),
129            Self::Unknown => "unknown".to_string(),
130        }
131    }
132}
133
134/// The transcript of the session at `address` on this machine, when its
135/// harness's records are read here.
136pub fn transcript(homes: &HarnessHomes, address: &MailAddress) -> Option<PathBuf> {
137    if address.harness != "claude-code" {
138        return None;
139    }
140    let file = format!("{}.jsonl", address.session_id);
141    std::fs::read_dir(&homes.claude_code)
142        .ok()?
143        .flatten()
144        .map(|project| project.path().join(&file))
145        .find(|path| path.is_file())
146}
147
148fn at_ms(value: &serde_json::Value) -> u64 {
149    value
150        .as_str()
151        .and_then(supercode_interchange::sidecar::rfc3339_to_ms)
152        .and_then(|at| u64::try_from(at).ok())
153        .unwrap_or_default()
154}
155
156fn prompt_text(content: &serde_json::Value) -> Option<String> {
157    let text = match content {
158        serde_json::Value::String(text) => text.clone(),
159        serde_json::Value::Array(parts) => parts
160            .iter()
161            .filter(|part| part["type"] == "text")
162            .filter_map(|part| part["text"].as_str())
163            .collect::<Vec<_>>()
164            .join("\n"),
165        _ => return None,
166    };
167    (!text.trim().is_empty()).then_some(text)
168}
169
170/// Mail ids rendered into `line` as the model reads them: the envelope's own
171/// `cross-session-message id="m-…"` (escaped or not, as Claude's relay nests
172/// it), or the relay's `(message m-…)` note.
173fn rendered_mail_ids(line: &str) -> Vec<&str> {
174    let mut ids = Vec::new();
175    for mark in ["cross-session-message id=\\\"", "(message "] {
176        for (at, _) in line.match_indices(mark) {
177            let rest = &line[at + mark.len()..];
178            let end = rest
179                .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
180                .unwrap_or(rest.len());
181            let id = &rest[..end];
182            if id.starts_with("m-") {
183                ids.push(id);
184            }
185        }
186    }
187    ids
188}
189
190/// Read a Claude Code transcript for its typed lines and delivered mail.
191pub fn read_claude(path: &Path) -> std::io::Result<TranscriptMail> {
192    let reader = std::io::BufReader::new(std::fs::File::open(path)?);
193    let mut mail = TranscriptMail::default();
194    // Lines handed to the harness while the session was busy, waiting in its
195    // queue: their words and when they were sent. A queue holds notices and
196    // other sessions' messages too, so a line counts as the user's only when
197    // it lands as theirs.
198    let mut queued: VecDeque<(String, u64)> = VecDeque::new();
199    // Lines taken into a running turn: their words and when.
200    let mut absorbed: VecDeque<(String, u64)> = VecDeque::new();
201    let take = |waiting: &mut VecDeque<(String, u64)>, text: &str| {
202        let at = waiting.iter().position(|(words, _)| words == text)?;
203        waiting.remove(at).map(|(_, when)| when)
204    };
205    for line in reader.lines() {
206        let line = line?;
207        let prompt = line.contains("\"promptSource\"");
208        let queue = line.contains("\"queue-operation\"");
209        let attachment = line.contains("\"queued_command\"");
210        let answer = line.contains("\"stop_reason\":\"end_turn\"")
211            || line.contains("\"stop_reason\":\"stop_sequence\"");
212        let rendered = line.contains("cross-session-message id=") || line.contains("(message m-");
213        if !(prompt || queue || attachment || rendered || answer) {
214            continue;
215        }
216        let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
217            continue;
218        };
219        if record["isSidechain"] == true {
220            continue;
221        }
222        let kind = record["type"].as_str().unwrap_or_default();
223        let at = at_ms(&record["timestamp"]);
224        // Mail in the model's context: a prompt, an attachment or a tool's
225        // result rendering it; never an assistant's own words naming an id,
226        // nor the queue still holding it.
227        if rendered && matches!(kind, "user" | "attachment") {
228            for id in rendered_mail_ids(&line) {
229                mail.delivered.entry(id.to_string()).or_insert(at);
230            }
231        }
232        match kind {
233            // A turn's final message is its answer to the user.
234            "assistant"
235                if answer
236                    && matches!(
237                        record["message"]["stop_reason"].as_str(),
238                        Some("end_turn" | "stop_sequence")
239                    ) =>
240            {
241                let (Some(native_id), Some(text)) = (
242                    record["uuid"].as_str(),
243                    prompt_text(&record["message"]["content"]),
244                ) else {
245                    continue;
246                };
247                mail.answers.push(Answer {
248                    native_id: native_id.to_string(),
249                    at_ms: at,
250                    text,
251                });
252            }
253            "queue-operation" => {
254                let Some(text) = record["content"].as_str() else {
255                    continue;
256                };
257                match record["operation"].as_str() {
258                    Some("enqueue") => queued.push_back((text.to_string(), at)),
259                    Some("remove") => {
260                        if take(&mut queued, text).is_some() {
261                            absorbed.push_back((text.to_string(), at));
262                        }
263                    }
264                    _ => {}
265                }
266            }
267            // A line taken into a running turn lands as a queued command; it
268            // is the user's when its origin is a person.
269            "attachment" => {
270                let attached = &record["attachment"];
271                let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
272                let Some(text) = attached["prompt"].as_str() else {
273                    continue;
274                };
275                if attached["type"] != "queued_command" || !human || text.trim().is_empty() {
276                    continue;
277                }
278                let delivered = take(&mut absorbed, text).unwrap_or(at);
279                mail.typed.push(TypedLine {
280                    sent_at_ms: at_ms(&attached["timestamp"]).min(delivered),
281                    delivered_at_ms: Some(delivered),
282                    text: text.to_string(),
283                });
284            }
285            "user" => {
286                let source = record["promptSource"].as_str();
287                if !matches!(source, Some("typed" | "queued")) || record["isMeta"] == true {
288                    continue;
289                }
290                let Some(text) = prompt_text(&record["message"]["content"]) else {
291                    continue;
292                };
293                let sent = match source {
294                    Some("queued") => take(&mut queued, &text).unwrap_or(at),
295                    _ => at,
296                };
297                mail.typed.push(TypedLine {
298                    sent_at_ms: sent,
299                    delivered_at_ms: Some(at),
300                    text,
301                });
302            }
303            _ => {}
304        }
305    }
306    // A line still in the queue is not filed: until it lands, the transcript
307    // does not say who wrote it.
308    Ok(mail)
309}
310
311/// The transcript's reading of the session at `address`, when it can be read.
312pub fn read(homes: &HarnessHomes, address: &MailAddress) -> Option<TranscriptMail> {
313    read_claude(&transcript(homes, address)?).ok()
314}
315
316/// File every line the user of `mailbox`'s session typed that is not filed
317/// yet, reading the transcript as `mail`. Returns how many were filed now.
318pub fn file_typed_lines(mailbox: &Mailbox, mail: &TranscriptMail) -> std::io::Result<usize> {
319    let address = mailbox.address();
320    let filed = mailbox.list()?;
321    let ids: HashSet<&str> = filed
322        .iter()
323        .map(|stored| stored.envelope.id.as_str())
324        .collect();
325    // What supercode typed into the pane is filed already, under its own id:
326    // each such turn accounts for one typed line with its words.
327    let mut typed_by_supercode: HashMap<&str, usize> = HashMap::new();
328    for stored in &filed {
329        if stored.envelope.kind == MailKind::User
330            && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
331        {
332            *typed_by_supercode
333                .entry(stored.envelope.body.trim())
334                .or_default() += 1;
335        }
336    }
337    let user = user_address(&address.machine)?;
338    let mut count = 0;
339    for line in &mail.typed {
340        let id = typed_line_id(address, line.sent_at_ms, &line.text);
341        if ids.contains(id.as_str()) {
342            continue;
343        }
344        if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
345            if *left > 0 {
346                *left -= 1;
347                continue;
348            }
349        }
350        mailbox.file_read(&Envelope {
351            id,
352            created_at_ms: line.sent_at_ms,
353            from: user.clone(),
354            from_name: format!("user@{}", address.machine),
355            kind: MailKind::User,
356            reply_via: ReplyVia::None,
357            in_reply_to: None,
358            in_reply_to_inferred: false,
359            native_from: None,
360            body: line.text.clone(),
361        })?;
362        count += 1;
363    }
364    Ok(count)
365}
366
367/// File every answer the session at `address` gave its user that is not filed
368/// yet, in the user's mailbox under `root`, from the session (`name` is what
369/// the user knows it by). Returns how many were filed now.
370pub fn file_answers(
371    root: &Path,
372    address: &MailAddress,
373    name: &str,
374    mail: &TranscriptMail,
375) -> std::io::Result<usize> {
376    let mailbox = Mailbox::open(root, &user_address(&address.machine)?)?;
377    let filed: HashSet<String> = mailbox
378        .list()?
379        .into_iter()
380        .map(|stored| stored.envelope.id)
381        .collect();
382    let mut count = 0;
383    for answer in &mail.answers {
384        let id = answer_id(address, &answer.native_id);
385        if filed.contains(&id) {
386            continue;
387        }
388        mailbox.file_read(&Envelope {
389            id,
390            created_at_ms: answer.at_ms,
391            from: address.clone(),
392            from_name: name.to_string(),
393            kind: MailKind::Peer,
394            reply_via: ReplyVia::None,
395            in_reply_to: None,
396            in_reply_to_inferred: false,
397            native_from: None,
398            body: answer.text.clone(),
399        })?;
400        count += 1;
401    }
402    Ok(count)
403}
404
405/// Everything the session at `address` sent that is filed on this machine
406/// under `root`: its messages to other sessions and its answers to its user,
407/// each with the mailbox it is filed in, oldest first.
408pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
409    let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
410        .into_iter()
411        .filter(|mailbox| mailbox.address() != address)
412        .flat_map(|mailbox| {
413            let to = mailbox.address().clone();
414            mailbox
415                .list()
416                .unwrap_or_default()
417                .into_iter()
418                .filter(|stored| &stored.envelope.from == address)
419                .map(move |stored| (to.clone(), stored))
420        })
421        .collect();
422    sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
423    sent
424}
425
426/// Where each of `stored` (the mailbox of the session at `address`) stands,
427/// by id, as the transcript reading `mail` shows it (`None`: unreadable).
428pub fn deliveries(
429    address: &MailAddress,
430    stored: &[StoredEnvelope],
431    mail: Option<&TranscriptMail>,
432) -> BTreeMap<String, Delivery> {
433    let Some(mail) = mail else {
434        return stored
435            .iter()
436            .map(|stored| (stored.envelope.id.clone(), Delivery::Unknown))
437            .collect();
438    };
439    let typed: HashMap<String, &TypedLine> = mail
440        .typed
441        .iter()
442        .map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
443        .collect();
444    // A turn supercode typed into the pane reaches the model as a typed line
445    // with its words, the first one sent after it was filed.
446    let mut used: HashSet<usize> = HashSet::new();
447    let mut result = BTreeMap::new();
448    for stored in stored {
449        let envelope = &stored.envelope;
450        let at = if let Some(line) = typed.get(&envelope.id) {
451            line.delivered_at_ms
452        } else if let Some(at) = mail.delivered.get(&envelope.id) {
453            Some(*at)
454        } else if envelope.kind == MailKind::User {
455            mail.typed
456                .iter()
457                .enumerate()
458                .find(|(index, line)| {
459                    !used.contains(index)
460                        && line.sent_at_ms + 1_000 >= envelope.created_at_ms
461                        && line.text.trim() == envelope.body.trim()
462                })
463                .and_then(|(index, line)| {
464                    used.insert(index);
465                    line.delivered_at_ms
466                })
467        } else {
468            None
469        };
470        result.insert(
471            envelope.id.clone(),
472            match at {
473                Some(at_ms) => Delivery::Delivered { at_ms },
474                None => Delivery::Sent,
475            },
476        );
477    }
478    result
479}
480
481/// Where the message `id` is filed on this machine: the mailbox of the
482/// session that received it, and the envelope.
483pub fn find_message(root: &Path, id: &str) -> std::io::Result<Option<(Mailbox, StoredEnvelope)>> {
484    for mailbox in crate::mailbox::all_mailboxes(root) {
485        if let Some(stored) = mailbox.find(id)? {
486            return Ok(Some((mailbox, stored)));
487        }
488    }
489    Ok(None)
490}