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. A line waiting in the
22//! queue is filed as sent; the queue also holds scheduled prompts and other
23//! sessions' messages, so a queued line that lands as someone else's is taken
24//! back out of the mailbox, and one removed from the queue unread is withdrawn. The id is derived from
25//! the session's address, the moment the line was sent and its words, so it is
26//! the same before and after the line reaches the model, and every reader
27//! derives the same one. A line supercode itself typed into the session's pane
28//! (a Room member's or a voice turn) is filed already under its own id.
29//!
30//! Mail supercode filed reaches the model as its rendering,
31//! `<cross-session-message id="m-…"`, inside a prompt or a queued-command
32//! attachment; a Room or voice turn reaches it as a typed line with its words.
33//!
34//! Codex rollouts do not record who wrote a user message (a typed line and the
35//! injected AGENTS.md or environment context look alike), so their typed lines
36//! are not filed; their mail is delivered once its rendering is in a user
37//! message or a tool's output. Other harnesses' records are not read here:
38//! their mail's delivery is unknown.
39
40use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
41use std::io::BufRead;
42use std::path::{Path, PathBuf};
43
44use serde::Serialize;
45
46use crate::mailbox::{Envelope, MailAddress, MailKind, Mailbox, ReplyVia, StoredEnvelope};
47use crate::HarnessHomes;
48
49/// Prefix of the id of a line a session's user typed.
50pub const TYPED_ID_PREFIX: &str = "u-";
51
52/// Prefix of the id of a channel line a harness received on its own channel
53/// (Claude Code's `[channel: …]` prompts, from a Room or another bridge).
54pub const CHANNEL_ID_PREFIX: &str = "c-";
55
56/// One line a session received on a channel of its harness's own: its header
57/// (`[channel: <room> · from: <who> · at: <time> …]`) and words.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ChannelLine {
60    /// The line's header, which names its channel, sender and send time.
61    pub header: String,
62    /// Who sent it, as the header names them.
63    pub from: String,
64    /// When it was sent, in epoch milliseconds.
65    pub sent_at_ms: u64,
66    /// When it reached the model; `None` while it waits in the harness's queue.
67    pub delivered_at_ms: Option<u64>,
68    /// The line, header included.
69    pub text: String,
70}
71
72/// The id of the channel line with `header` in the session at `address`.
73pub fn channel_line_id(address: &MailAddress, header: &str) -> String {
74    let hash = blake3::hash(format!("{address}\n{header}").as_bytes()).to_hex();
75    format!("{CHANNEL_ID_PREFIX}{}", &hash[..24])
76}
77
78/// The channel lines in a prompt (several arrive as one when they waited
79/// together), each from its `[channel: …]` header to the next.
80fn channel_lines(text: &str, fallback_ms: u64, delivered_at_ms: Option<u64>) -> Vec<ChannelLine> {
81    let starts: Vec<usize> = text
82        .match_indices("[channel: ")
83        .map(|(at, _)| at)
84        .filter(|at| *at == 0 || text[..*at].ends_with('\n'))
85        .collect();
86    starts
87        .iter()
88        .enumerate()
89        .filter_map(|(n, start)| {
90            let end = starts.get(n + 1).copied().unwrap_or(text.len());
91            let line = text[*start..end].trim_end();
92            let header = &line[..line.find(']')? + 1];
93            let field = |name: &str| {
94                header
95                    .split(" · ")
96                    .find_map(|part| part.strip_prefix(name))
97                    .map(|value| value.trim_end_matches(']').trim().to_string())
98            };
99            Some(ChannelLine {
100                header: header.to_string(),
101                from: field("from: ").unwrap_or_else(|| "a channel".to_string()),
102                sent_at_ms: field("at: ")
103                    .and_then(|at| supercode_interchange::sidecar::rfc3339_to_ms(&at))
104                    .and_then(|at| u64::try_from(at).ok())
105                    .unwrap_or(fallback_ms),
106                delivered_at_ms,
107                text: line.to_string(),
108            })
109        })
110        .collect()
111}
112
113/// Prefix of the id of a session's answer to its user.
114pub const ANSWER_ID_PREFIX: &str = "a-";
115
116/// The id of the answer the transcript entry `native_id` records in the
117/// session at `address`: `a-` and 24 hex digits.
118pub fn answer_id(address: &MailAddress, native_id: &str) -> String {
119    let hash = blake3::hash(format!("{address}\n{native_id}").as_bytes()).to_hex();
120    format!("{ANSWER_ID_PREFIX}{}", &hash[..24])
121}
122
123/// The mailbox of the person who uses the sessions on `machine`: where the
124/// lines they type come from and where the sessions' answers go.
125pub fn user_address(machine: &str) -> std::io::Result<MailAddress> {
126    MailAddress::new(machine.to_string(), "operator", "user")
127        .map_err(|error| std::io::Error::other(error.0))
128}
129
130/// The id of the line with `text` that the user of the session at `address`
131/// sent at `sent_at_ms`: `u-` and 24 hex digits.
132pub fn typed_line_id(address: &MailAddress, sent_at_ms: u64, text: &str) -> String {
133    let hash = blake3::hash(format!("{address}\n{sent_at_ms}\n{text}").as_bytes()).to_hex();
134    format!("{TYPED_ID_PREFIX}{}", &hash[..24])
135}
136
137/// One line the session's user typed, as its transcript records it.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct TypedLine {
140    /// When the user sent it, in epoch milliseconds.
141    pub sent_at_ms: u64,
142    /// When it reached the model; `None` while it waits in the harness's queue.
143    pub delivered_at_ms: Option<u64>,
144    /// Taken back out of the queue before it reached the model.
145    pub withdrawn: bool,
146    /// The words, as typed.
147    pub text: String,
148}
149
150/// One answer the session gave its user: a turn's final message.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Answer {
153    /// The transcript entry's own id.
154    pub native_id: String,
155    /// When it was written, in epoch milliseconds.
156    pub at_ms: u64,
157    /// Its words.
158    pub text: String,
159}
160
161/// What one transcript says about a session's mail.
162#[derive(Debug, Clone, Default)]
163pub struct TranscriptMail {
164    /// The lines the session's user typed, in the order they were sent.
165    pub typed: Vec<TypedLine>,
166    /// The session's answers to its user, in order.
167    pub answers: Vec<Answer>,
168    /// Lines the session received on its harness's own channels, in order.
169    pub channel: Vec<ChannelLine>,
170    /// Each filed message the model has seen, by id: when it first did.
171    pub delivered: HashMap<String, u64>,
172}
173
174/// Where a message stands.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(tag = "state", rename_all = "snake_case")]
177pub enum Delivery {
178    /// In the model's context since this moment (epoch milliseconds).
179    Delivered {
180        /// When it first was.
181        at_ms: u64,
182    },
183    /// Sent to the session, not yet in its model's context.
184    Sent,
185    /// Taken back out of the harness's queue before it reached the model.
186    Withdrawn,
187    /// The session's transcript cannot be read here, so whether it reached
188    /// the model is not known.
189    Unknown,
190    /// Its receiver's transcript was not read for this listing (it reads only
191    /// the newest receivers'); `supercode message show <id>` reads it.
192    NotRead,
193    /// Delivered by this very read: the receiver's own `message inbox` claimed it now (epoch milliseconds), and the
194    /// listing printing it is how it reaches the model.
195    ByThisRead {
196        /// When this read claimed it.
197        at_ms: u64,
198    },
199}
200
201impl Delivery {
202    /// How an inbox listing says it.
203    pub fn describe(&self) -> String {
204        match self {
205            Self::Delivered { at_ms } => {
206                supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
207            }
208            Self::Sent => "not yet".to_string(),
209            Self::Withdrawn => "never (taken back)".to_string(),
210            Self::Unknown => "unknown".to_string(),
211            Self::NotRead => "not read here (supercode message show reads it)".to_string(),
212            Self::ByThisRead { at_ms } => format!(
213                "now, by this read ({})",
214                supercode_interchange::sidecar::ms_to_rfc3339(*at_ms as i64)
215            ),
216        }
217    }
218}
219
220/// The transcript of the session at `address` on this machine, when its
221/// harness's records are read here.
222pub fn transcript(homes: &HarnessHomes, address: &MailAddress) -> Option<PathBuf> {
223    if address.harness != "claude-code" {
224        return None;
225    }
226    let file = format!("{}.jsonl", address.session_id);
227    std::fs::read_dir(&homes.claude_code)
228        .ok()?
229        .flatten()
230        .map(|project| project.path().join(&file))
231        .find(|path| path.is_file())
232}
233
234fn at_ms(value: &serde_json::Value) -> u64 {
235    value
236        .as_str()
237        .and_then(supercode_interchange::sidecar::rfc3339_to_ms)
238        .and_then(|at| u64::try_from(at).ok())
239        .unwrap_or_default()
240}
241
242fn prompt_text(content: &serde_json::Value) -> Option<String> {
243    let text = match content {
244        serde_json::Value::String(text) => text.clone(),
245        serde_json::Value::Array(parts) => parts
246            .iter()
247            .filter(|part| part["type"] == "text")
248            .filter_map(|part| part["text"].as_str())
249            .collect::<Vec<_>>()
250            .join("\n"),
251        _ => return None,
252    };
253    (!text.trim().is_empty()).then_some(text)
254}
255
256/// Mail ids rendered into `line` as the model reads them: the envelope's own
257/// `cross-session-message id="m-…"` (escaped or not, as Claude's relay nests
258/// it), or the relay's `(message m-…)` note.
259fn rendered_mail_ids(line: &str) -> Vec<&str> {
260    let mut ids = Vec::new();
261    for mark in ["cross-session-message id=\\\"", "(message "] {
262        for (at, _) in line.match_indices(mark) {
263            let rest = &line[at + mark.len()..];
264            let end = rest
265                .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
266                .unwrap_or(rest.len());
267            let id = &rest[..end];
268            if id.starts_with("m-") {
269                ids.push(id);
270            }
271        }
272    }
273    ids
274}
275
276/// Whether a line handed to a harness's queue is machine-made on its face: supercode's mail or
277/// notices, or a background task's report.
278fn machine_made(text: &str) -> bool {
279    text.contains("<cross-session-message")
280        || text.starts_with("[Cross-session")
281        || text.starts_with("[channel: ")
282        || text.starts_with("<task-notification>")
283}
284
285/// Read a Claude Code transcript for its typed lines and delivered mail.
286pub fn read_claude(path: &Path) -> std::io::Result<TranscriptMail> {
287    let reader = std::io::BufReader::new(std::fs::File::open(path)?);
288    let mut mail = TranscriptMail::default();
289    // The harness's queue, mirrored in full (it is first in, first out): each
290    // line's words and, when it may be the user's, its entry in `mail.typed`.
291    // A queue also holds scheduled prompts and other sessions' messages, so a
292    // line waiting there is the user's only provisionally, until it lands as
293    // theirs.
294    let mut queued: VecDeque<(String, Option<usize>)> = VecDeque::new();
295    // How many lines the queue has let go since the last prompt: they land
296    // together as the next one.
297    let mut dequeued = 0_usize;
298    // Lines taken into a running turn, waiting for the attachment that says
299    // who wrote them.
300    let mut absorbed: VecDeque<(String, Option<usize>)> = VecDeque::new();
301    let take = |waiting: &mut VecDeque<(String, Option<usize>)>, text: &str| {
302        let at = waiting.iter().position(|(words, _)| words == text)?;
303        waiting.remove(at).map(|(_, index)| index)
304    };
305    // Queued lines that landed as someone else's.
306    let mut not_typed: HashSet<usize> = HashSet::new();
307    // When each line left the queue for a running turn. Its queued-command
308    // attachment carries the time it was queued, not this one.
309    let mut taken_in: HashMap<String, VecDeque<u64>> = HashMap::new();
310    // Attachments written before their line's remove (older harness builds
311    // write them first): their words, the mail they render, the typed line
312    // they carry, and their own stamp should no remove come.
313    let mut early: Vec<(String, Vec<String>, Option<usize>, u64)> = Vec::new();
314    for line in reader.lines() {
315        let line = line?;
316        let prompt = line.contains("\"promptSource\"") || line.contains("\"type\":\"user\"");
317        let queue = line.contains("\"queue-operation\"");
318        let attachment = line.contains("\"queued_command\"");
319        let answer = line.contains("\"stop_reason\":\"end_turn\"")
320            || line.contains("\"stop_reason\":\"stop_sequence\"");
321        let rendered = line.contains("cross-session-message id=") || line.contains("(message m-");
322        if !(prompt || queue || attachment || rendered || answer) {
323            continue;
324        }
325        let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
326            continue;
327        };
328        if record["isSidechain"] == true {
329            continue;
330        }
331        let kind = record["type"].as_str().unwrap_or_default();
332        let mut at = at_ms(&record["timestamp"]);
333        if kind == "queue-operation" && record["operation"] == "remove" {
334            if let Some(text) = record["content"].as_str() {
335                // The remove an early attachment waited for: it entered the
336                // context now.
337                if let Some(found) = early.iter().position(|(words, ..)| words == text) {
338                    let (_, ids, index, _) = early.remove(found);
339                    for id in ids {
340                        mail.delivered.entry(id).or_insert(at);
341                    }
342                    if let Some(index) = index {
343                        mail.typed[index].delivered_at_ms = Some(at);
344                    }
345                    continue;
346                }
347                taken_in.entry(text.to_string()).or_default().push_back(at);
348            }
349        }
350        let mut waits_for_remove = false;
351        if kind == "attachment" && record["attachment"]["type"] == "queued_command" {
352            match record["attachment"]["prompt"]
353                .as_str()
354                .and_then(|text| taken_in.get_mut(text))
355                .and_then(VecDeque::pop_front)
356            {
357                Some(taken) => at = taken,
358                None => waits_for_remove = true,
359            }
360        }
361        if waits_for_remove {
362            let attached = &record["attachment"];
363            let text = attached["prompt"].as_str().unwrap_or_default().to_string();
364            let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
365            let ids = rendered_mail_ids(&line)
366                .into_iter()
367                .map(str::to_string)
368                .collect();
369            // Its line leaves the queue with the remove still to come.
370            let index = match take(&mut queued, &text) {
371                Some(Some(index)) if !human => {
372                    not_typed.insert(index);
373                    None
374                }
375                Some(index) => index,
376                None if human && !text.trim().is_empty() => {
377                    mail.typed.push(TypedLine {
378                        sent_at_ms: at,
379                        delivered_at_ms: None,
380                        withdrawn: false,
381                        text: text.clone(),
382                    });
383                    Some(mail.typed.len() - 1)
384                }
385                None => None,
386            };
387            early.push((text, ids, index, at));
388            continue;
389        }
390        // Mail in the model's context: a prompt, an attachment or a tool's
391        // result rendering it; never an assistant's own words naming an id,
392        // nor the queue still holding it.
393        if rendered && matches!(kind, "user" | "attachment") {
394            for id in rendered_mail_ids(&line) {
395                mail.delivered.entry(id.to_string()).or_insert(at);
396            }
397        }
398        match kind {
399            // A turn's final message is its answer to the user.
400            "assistant"
401                if answer
402                    && matches!(
403                        record["message"]["stop_reason"].as_str(),
404                        Some("end_turn" | "stop_sequence")
405                    ) =>
406            {
407                let (Some(native_id), Some(text)) = (
408                    record["uuid"].as_str(),
409                    prompt_text(&record["message"]["content"]),
410                ) else {
411                    continue;
412                };
413                mail.answers.push(Answer {
414                    native_id: native_id.to_string(),
415                    at_ms: at,
416                    text,
417                });
418            }
419            // A dequeue names no line: the queue lets go of its oldest.
420            "queue-operation" if record["operation"] == "dequeue" => dequeued += 1,
421            "queue-operation" => {
422                let Some(text) = record["content"].as_str() else {
423                    continue;
424                };
425                match record["operation"].as_str() {
426                    Some("enqueue") => {
427                        let index = (!machine_made(text) && !text.trim().is_empty()).then(|| {
428                            mail.typed.push(TypedLine {
429                                sent_at_ms: at,
430                                delivered_at_ms: None,
431                                withdrawn: false,
432                                text: text.to_string(),
433                            });
434                            mail.typed.len() - 1
435                        });
436                        queued.push_back((text.to_string(), index));
437                    }
438                    Some("remove") => {
439                        let Some(index) = take(&mut queued, text) else {
440                            continue;
441                        };
442                        match record["reason"].as_str() {
443                            // Taken into the running turn: its attachment follows.
444                            Some("absorbed_mid_turn" | "delivered_to_agent") => {
445                                if let Some(index) = index {
446                                    mail.typed[index].delivered_at_ms = Some(at);
447                                }
448                                absorbed.push_back((text.to_string(), index));
449                            }
450                            // Taken back before it reached the model.
451                            _ => {
452                                if let Some(index) = index {
453                                    mail.typed[index].withdrawn = true;
454                                }
455                            }
456                        }
457                    }
458                    _ => {}
459                }
460            }
461            // A line taken into a running turn lands as a queued command; it
462            // is the user's when its origin is a person.
463            "attachment" => {
464                let attached = &record["attachment"];
465                let Some(text) = attached["prompt"].as_str() else {
466                    continue;
467                };
468                if attached["type"] != "queued_command" || text.trim().is_empty() {
469                    continue;
470                }
471                let human = attached["origin"]["kind"] == "human" || attached["humanTurn"] == true;
472                match take(&mut absorbed, text) {
473                    Some(Some(index)) if !human => {
474                        not_typed.insert(index);
475                    }
476                    Some(_) => {}
477                    None if human => mail.typed.push(TypedLine {
478                        sent_at_ms: at_ms(&attached["timestamp"]).min(at),
479                        delivered_at_ms: Some(at),
480                        withdrawn: false,
481                        text: text.to_string(),
482                    }),
483                    None => {}
484                }
485            }
486            "user" => {
487                let Some(text) = prompt_text(&record["message"]["content"]) else {
488                    continue;
489                };
490                let source = record["promptSource"].as_str();
491                let human = matches!(source, Some("typed" | "queued")) && record["isMeta"] != true;
492                if !human {
493                    mail.channel.extend(channel_lines(&text, at, Some(at)));
494                }
495                // Queued lines land as the next prompt when the turn they waited
496                // for ends, several at once when several were let go: the lines
497                // whose words it carries (the queue's oldest when it carries
498                // none, since the transcript does not record every way a queue
499                // empties, and matching by words keeps one lost line from
500                // shifting every later one).
501                let mut landing: Vec<Option<usize>> = Vec::new();
502                let let_go = std::mem::take(&mut dequeued);
503                if let_go == 0 {
504                    landing.extend(take(&mut queued, &text));
505                }
506                let wanted = let_go;
507                while landing.len() < wanted {
508                    let Some(at) = queued
509                        .iter()
510                        .position(|(words, _)| text.contains(words.as_str()))
511                    else {
512                        break;
513                    };
514                    landing.extend(queued.remove(at).map(|(_, index)| index));
515                }
516                if landing.is_empty() && wanted > 0 {
517                    for _ in 0..wanted {
518                        landing.extend(queued.pop_front().map(|(_, index)| index));
519                    }
520                }
521                if !landing.is_empty() {
522                    for index in landing.into_iter().flatten() {
523                        if human {
524                            mail.typed[index].delivered_at_ms = Some(at);
525                        } else {
526                            not_typed.insert(index);
527                        }
528                    }
529                    continue;
530                }
531                if human {
532                    mail.typed.push(TypedLine {
533                        sent_at_ms: at,
534                        delivered_at_ms: Some(at),
535                        withdrawn: false,
536                        text,
537                    });
538                }
539            }
540            _ => {}
541        }
542    }
543    // An early attachment whose remove never came is in the context all the
544    // same: it counts from its own stamp.
545    for (_, ids, index, at) in early {
546        for id in ids {
547            mail.delivered.entry(id).or_insert(at);
548        }
549        if let Some(index) = index {
550            mail.typed[index].delivered_at_ms.get_or_insert(at);
551        }
552    }
553    // A line still queued stays provisional: sent, not delivered.
554    for (text, _) in &queued {
555        if text.starts_with("[channel: ") {
556            mail.channel.extend(channel_lines(text, 0, None));
557        }
558    }
559    let mut index = 0;
560    mail.typed.retain(|_| {
561        index += 1;
562        !not_typed.contains(&(index - 1))
563    });
564    Ok(mail)
565}
566
567/// The transcript's reading of the session at `address`, when it can be read.
568pub fn read(homes: &HarnessHomes, address: &MailAddress) -> Option<TranscriptMail> {
569    if address.harness == "codex" {
570        return read_codex_answers(&crate::mail_question::transcript_for(address)?).ok();
571    }
572    read_claude(&transcript(homes, address)?).ok()
573}
574
575/// Final answers and delivered mail from a native Codex pane's own transcript.
576/// Question replies have separate native receipts; neither tool outputs nor
577/// developer context are invented as typed user turns here.
578fn read_codex_answers(path: &Path) -> std::io::Result<TranscriptMail> {
579    let reader = std::io::BufReader::new(std::fs::File::open(path)?);
580    let mut mail = TranscriptMail::default();
581    for line in reader.lines().map_while(Result::ok) {
582        let rendered = line.contains("cross-session-message id=");
583        if !rendered && !line.contains("\"final_answer\"") {
584            continue;
585        }
586        let Ok(record) = serde_json::from_str::<serde_json::Value>(&line) else {
587            continue;
588        };
589        let item = &record["payload"];
590        // Mail in the model's context, as Claude's reading counts it: a user
591        // message (a pasted turn) or a tool's output (`supercode message inbox`
592        // run in a turn) rendering it; never the model's own words naming an id,
593        // nor the event stream echoing those items.
594        if rendered
595            && record["type"] == "response_item"
596            && ((item["type"] == "message" && item["role"] == "user")
597                || matches!(
598                    item["type"].as_str(),
599                    Some("function_call_output" | "custom_tool_call_output")
600                ))
601        {
602            let at = at_ms(&record["timestamp"]);
603            for id in codex_rendered_mail_ids(&line) {
604                mail.delivered.entry(id.to_string()).or_insert(at);
605            }
606        }
607        if record["type"] != "response_item"
608            || item["type"] != "message"
609            || item["role"] != "assistant"
610            || item["phase"] != "final_answer"
611        {
612            continue;
613        }
614        let Some(id) = item["id"].as_str() else {
615            continue;
616        };
617        let text = item["content"]
618            .as_array()
619            .into_iter()
620            .flatten()
621            .filter(|part| part["type"] == "output_text")
622            .filter_map(|part| part["text"].as_str())
623            .collect::<Vec<_>>()
624            .join("\n");
625        if !text.is_empty() {
626            mail.answers.push(Answer {
627                native_id: id.to_string(),
628                at_ms: at_ms(&record["timestamp"]),
629                text,
630            });
631        }
632    }
633    Ok(mail)
634}
635
636/// Mail ids rendered into a Codex rollout line: `cross-session-message id="m-…"`
637/// with the quote escaped once (a user message) or more (a tool's output is
638/// JSON text inside the record's JSON).
639fn codex_rendered_mail_ids(line: &str) -> Vec<&str> {
640    let mark = "cross-session-message id=";
641    line.match_indices(mark)
642        .filter_map(|(at, _)| {
643            let rest = line[at + mark.len()..].trim_start_matches(['\\', '"']);
644            let end = rest
645                .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
646                .unwrap_or(rest.len());
647            Some(&rest[..end]).filter(|id| id.starts_with("m-"))
648        })
649        .collect()
650}
651
652/// The newest of `items` (time, id), sorted by time, sent before `at` (or at
653/// it, when `inclusive`).
654fn newest_before(items: &[(u64, String)], at: u64, inclusive: bool) -> Option<String> {
655    items
656        .iter()
657        .rev()
658        .find(|(when, _)| if inclusive { *when <= at } else { *when < at })
659        .map(|(_, id)| id.clone())
660}
661
662/// Each answer the session gave, as (when, id), oldest first.
663fn answer_times(address: &MailAddress, mail: &TranscriptMail) -> Vec<(u64, String)> {
664    let mut times: Vec<(u64, String)> = mail
665        .answers
666        .iter()
667        .map(|answer| (answer.at_ms, answer_id(address, &answer.native_id)))
668        .collect();
669    times.sort();
670    times
671}
672
673/// File `envelope` unless it is filed as it stands; a record an earlier
674/// reading filed differently (without its inferred reply) is filed again.
675fn file_derived(
676    mailbox: &Mailbox,
677    filed: &HashMap<&str, &StoredEnvelope>,
678    envelope: Envelope,
679) -> std::io::Result<bool> {
680    if let Some(stored) = filed.get(envelope.id.as_str()) {
681        if stored.envelope.in_reply_to == envelope.in_reply_to {
682            return Ok(false);
683        }
684        std::fs::remove_file(&stored.path).ok();
685    }
686    mailbox.file_read(&envelope)?;
687    Ok(true)
688}
689
690/// File every line the user of `mailbox`'s session typed that is not filed
691/// yet, reading the transcript as `mail`. Returns how many were filed now.
692pub fn file_typed_lines(mailbox: &Mailbox, mail: &TranscriptMail) -> std::io::Result<usize> {
693    let address = mailbox.address();
694    let filed = mailbox.list()?;
695    let by_id: HashMap<&str, &StoredEnvelope> = filed
696        .iter()
697        .map(|stored| (stored.envelope.id.as_str(), stored))
698        .collect();
699    // A line a person sends answers the session's newest answer before it:
700    // the reply is inferred, as a mail client threads a reply by its subject.
701    let answers = answer_times(address, mail);
702    // What supercode typed into the pane is filed already, under its own id:
703    // each such turn accounts for one typed line with its words.
704    let mut typed_by_supercode: HashMap<&str, usize> = HashMap::new();
705    for stored in &filed {
706        if stored.envelope.kind == MailKind::User
707            && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
708        {
709            *typed_by_supercode
710                .entry(stored.envelope.body.trim())
711                .or_default() += 1;
712        }
713    }
714    let user = user_address(&address.machine)?;
715    // A typed line's record is the transcript's reading: one the reading no
716    // longer yields (a queued line that landed as a scheduled prompt's or a
717    // session's, or one an older reading took for the user's) is taken back.
718    let current: HashSet<String> = mail
719        .typed
720        .iter()
721        .map(|line| typed_line_id(address, line.sent_at_ms, &line.text))
722        .collect();
723    // A Codex transcript does not say who wrote a prompt: its typed lines come from its
724    // UserPromptSubmit hook ([`file_prompt`]), never from this reading, so none is taken back here.
725    let prunes = address.harness != "codex";
726    for stored in &filed {
727        // Only this session's own records (user mail): a line typed in another session and copied here as CC
728        // (`MailKind::Typed`, docs/adr/0008) shares the id prefix but is never this transcript's to take back.
729        if prunes
730            && stored.envelope.kind == MailKind::User
731            && stored.envelope.id.starts_with(TYPED_ID_PREFIX)
732            && !stored
733                .envelope
734                .in_reply_to
735                .as_deref()
736                .is_some_and(|id| id.starts_with("q-"))
737            && !current.contains(&stored.envelope.id)
738        {
739            std::fs::remove_file(&stored.path).ok();
740        }
741    }
742    let mut count = 0;
743    for line in &mail.typed {
744        let id = typed_line_id(address, line.sent_at_ms, &line.text);
745        if !by_id.contains_key(id.as_str()) {
746            if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
747                if *left > 0 {
748                    *left -= 1;
749                    continue;
750                }
751            }
752        }
753        let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
754        count += usize::from(file_derived(
755            mailbox,
756            &by_id,
757            Envelope {
758                id,
759                created_at_ms: line.sent_at_ms,
760                from: user.clone(),
761                from_name: format!("user@{}", address.machine),
762                sender_identity: None,
763                kind: MailKind::User,
764                reply_via: ReplyVia::None,
765                in_reply_to_inferred: in_reply_to.is_some(),
766                in_reply_to,
767                thread: None,
768                native_from: None,
769                voice_for: None,
770                subject: None,
771                body: line.text.clone(),
772            },
773        )?);
774    }
775    for line in &mail.channel {
776        let id = channel_line_id(address, &line.header);
777        let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
778        count += usize::from(file_derived(
779            mailbox,
780            &by_id,
781            Envelope {
782                id,
783                created_at_ms: line.sent_at_ms,
784                from: MailAddress::new(address.machine.clone(), "operator", "channel")
785                    .map_err(|error| std::io::Error::other(error.0))?,
786                from_name: line.from.clone(),
787                sender_identity: None,
788                kind: MailKind::Channel,
789                reply_via: ReplyVia::None,
790                in_reply_to_inferred: in_reply_to.is_some(),
791                in_reply_to,
792                thread: None,
793                native_from: None,
794                voice_for: None,
795                subject: None,
796                body: line.text.clone(),
797            },
798        )?);
799    }
800    Ok(count)
801}
802
803/// File a line a person typed into the session at `address`, as its harness's
804/// UserPromptSubmit hook reports it, for a harness whose transcript does not say
805/// who wrote a prompt (Codex). Supercode's own mail (its envelopes, and the user's
806/// turns it typed, filed under their own ids) is not a typed line. Returns whether
807/// it was filed now.
808pub fn file_prompt(address: &MailAddress, text: &str, sent_at_ms: u64) -> std::io::Result<bool> {
809    if text.trim().is_empty() || machine_made(text) {
810        return Ok(false);
811    }
812    let mailbox = Mailbox::open(&crate::mailbox::mail_root(), address)?;
813    let filed = mailbox.list()?;
814    let id = typed_line_id(address, sent_at_ms, text);
815    let typed_by_supercode = filed.iter().any(|stored| {
816        stored.envelope.kind == MailKind::User
817            && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
818            && stored.envelope.body.trim() == text.trim()
819            && sent_at_ms.saturating_sub(stored.envelope.created_at_ms) < 15 * 60 * 1000
820    });
821    if typed_by_supercode || filed.iter().any(|stored| stored.envelope.id == id) {
822        return Ok(false);
823    }
824    mailbox.file_read(&Envelope {
825        id,
826        created_at_ms: sent_at_ms,
827        from: user_address(&address.machine)?,
828        from_name: format!("user@{}", address.machine),
829        sender_identity: None,
830        kind: MailKind::User,
831        reply_via: ReplyVia::None,
832        in_reply_to: None,
833        in_reply_to_inferred: false,
834        thread: None,
835        native_from: None,
836        voice_for: None,
837        subject: None,
838        body: text.to_string(),
839    })?;
840    Ok(true)
841}
842
843/// File every answer the session at `address` gave its user that is not filed
844/// yet, in the user's mailbox under `root`, from the session (`name` is what
845/// the user knows it by). Returns how many were filed now.
846pub fn file_answers(
847    root: &Path,
848    address: &MailAddress,
849    name: &str,
850    mail: &TranscriptMail,
851) -> std::io::Result<usize> {
852    let recipient =
853        crate::mail_question::creator_for(address).unwrap_or(user_address(&address.machine)?);
854    let mailbox = Mailbox::open(root, &recipient)?;
855    let listed = mailbox.list()?;
856    let mut filed: HashMap<&str, &StoredEnvelope> = HashMap::new();
857    for stored in &listed {
858        // An answer an earlier reading filed as a session's mail is filed again as an answer.
859        if stored.envelope.id.starts_with(ANSWER_ID_PREFIX)
860            && stored.envelope.kind != MailKind::Answer
861        {
862            std::fs::remove_file(&stored.path).ok();
863            continue;
864        }
865        filed.insert(stored.envelope.id.as_str(), stored);
866    }
867    // An answer answers the newest line a person sent the session before it: a
868    // line typed or spoken to it, a Room member's turn, a channel line.
869    let mut people: Vec<(u64, String)> = mail
870        .typed
871        .iter()
872        .map(|line| {
873            (
874                line.sent_at_ms,
875                typed_line_id(address, line.sent_at_ms, &line.text),
876            )
877        })
878        .chain(
879            mail.channel
880                .iter()
881                .map(|line| (line.sent_at_ms, channel_line_id(address, &line.header))),
882        )
883        .chain(
884            Mailbox::open(root, address)?
885                .list()?
886                .into_iter()
887                .filter(|stored| {
888                    stored.envelope.kind == MailKind::User
889                        && (!stored.envelope.id.starts_with(TYPED_ID_PREFIX)
890                            || stored
891                                .envelope
892                                .in_reply_to
893                                .as_deref()
894                                .is_some_and(|id| id.starts_with("q-")))
895                })
896                .map(|stored| (stored.envelope.created_at_ms, stored.envelope.id)),
897        )
898        .collect();
899    people.sort();
900    let mut count = 0;
901    for answer in &mail.answers {
902        let id = answer_id(address, &answer.native_id);
903        let in_reply_to = newest_before(&people, answer.at_ms, true);
904        let envelope = Envelope {
905            id,
906            created_at_ms: answer.at_ms,
907            from: address.clone(),
908            from_name: name.to_string(),
909            sender_identity: None,
910            kind: MailKind::Answer,
911            reply_via: ReplyVia::None,
912            in_reply_to_inferred: in_reply_to.is_some(),
913            in_reply_to,
914            thread: None,
915            native_from: None,
916            voice_for: None,
917            subject: None,
918            body: answer.text.clone(),
919        };
920        if recipient.machine != address.machine && !filed.contains_key(envelope.id.as_str()) {
921            // Keep the local outbound mirror only after the creator's machine
922            // accepts it. A failed remote delivery can be retried on the next read.
923            crate::mailbox::deliver_to(&recipient, &envelope)?;
924        }
925        count += usize::from(file_derived(&mailbox, &filed, envelope)?);
926    }
927    Ok(count)
928}
929
930/// Everything the session at `address` sent that is filed on this machine
931/// under `root`: its messages to other sessions and its answers to its user,
932/// each with the mailbox it is filed in, oldest first.
933pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
934    let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
935        .into_iter()
936        .filter(|mailbox| mailbox.address() != address)
937        .flat_map(|mailbox| {
938            let to = mailbox.address().clone();
939            mailbox
940                .list()
941                .unwrap_or_default()
942                .into_iter()
943                .filter(|stored| &stored.envelope.from == address)
944                .map(move |stored| (to.clone(), stored))
945        })
946        .collect();
947    sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
948    sent
949}
950
951/// Where each of `stored` (the mailbox of the session at `address`) stands,
952/// by id, as the transcript reading `mail` shows it (`None`: unreadable).
953pub fn deliveries(
954    address: &MailAddress,
955    stored: &[StoredEnvelope],
956    mail: Option<&TranscriptMail>,
957) -> BTreeMap<String, Delivery> {
958    let Some(mail) = mail else {
959        return stored
960            .iter()
961            .map(|stored| {
962                (
963                    stored.envelope.id.clone(),
964                    crate::mail_question::delivered_at(&stored.envelope)
965                        .map(|at_ms| Delivery::Delivered { at_ms })
966                        .unwrap_or(Delivery::Unknown),
967                )
968            })
969            .collect();
970    };
971    let typed: HashMap<String, &TypedLine> = mail
972        .typed
973        .iter()
974        .map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
975        .collect();
976    let channel: HashMap<String, &ChannelLine> = mail
977        .channel
978        .iter()
979        .map(|line| (channel_line_id(address, &line.header), line))
980        .collect();
981    // A turn supercode typed into the pane reaches the model as a typed line
982    // with its words, the first one sent after it was filed.
983    let mut used: HashSet<usize> = HashSet::new();
984    let mut result = BTreeMap::new();
985    for stored in stored {
986        let envelope = &stored.envelope;
987        if typed.get(&envelope.id).is_some_and(|line| line.withdrawn) {
988            result.insert(envelope.id.clone(), Delivery::Withdrawn);
989            continue;
990        }
991        let at = if let Some(line) = typed.get(&envelope.id) {
992            line.delivered_at_ms
993        } else if let Some(line) = channel.get(&envelope.id) {
994            line.delivered_at_ms
995        } else if let Some(at) = mail
996            .delivered
997            .get(&envelope.id)
998            .or_else(|| mail.delivered.get(crate::mailbox::short_id(&envelope.id)))
999        {
1000            Some(*at)
1001        } else if envelope.kind == MailKind::User
1002            && envelope
1003                .in_reply_to
1004                .as_deref()
1005                .is_some_and(|id| id.starts_with("q-"))
1006        {
1007            crate::mail_question::delivered_at(envelope)
1008        } else if envelope.kind == MailKind::User {
1009            mail.typed
1010                .iter()
1011                .enumerate()
1012                .find(|(index, line)| {
1013                    !used.contains(index)
1014                        && line.sent_at_ms + 1_000 >= envelope.created_at_ms
1015                        && line.text.trim() == envelope.body.trim()
1016                })
1017                .and_then(|(index, line)| {
1018                    used.insert(index);
1019                    line.delivered_at_ms
1020                })
1021        } else {
1022            None
1023        };
1024        result.insert(
1025            envelope.id.clone(),
1026            match at {
1027                Some(at_ms) => Delivery::Delivered { at_ms },
1028                None => Delivery::Sent,
1029            },
1030        );
1031    }
1032    result
1033}
1034
1035/// Fewest hex digits after the kind (`m-`, `u-`, `a-`) a prefix may give.
1036pub const MIN_PREFIX_DIGITS: usize = 4;
1037
1038/// The messages filed on this machine under `root` whose id is `id` or
1039/// starts with it, like git's short hashes: the mailbox holding each, and the
1040/// envelope. A prefix shorter than the kind and [`MIN_PREFIX_DIGITS`] digits
1041/// matches nothing; more than one match means the prefix is ambiguous.
1042pub fn find_messages(root: &Path, id: &str) -> std::io::Result<Vec<(Mailbox, StoredEnvelope)>> {
1043    Ok(find_messages_many(root, &[id])?.remove(0))
1044}
1045
1046/// [`find_messages`] for several ids in one pass over this machine's mailboxes: one list per id,
1047/// in the order given.
1048pub fn find_messages_many(
1049    root: &Path,
1050    ids: &[&str],
1051) -> std::io::Result<Vec<Vec<(Mailbox, StoredEnvelope)>>> {
1052    let mut found: Vec<Vec<(Mailbox, StoredEnvelope)>> = ids.iter().map(|_| Vec::new()).collect();
1053    // A prefix shorter than the kind and MIN_PREFIX_DIGITS digits matches nothing.
1054    let usable: Vec<usize> = (0..ids.len())
1055        .filter(|&index| {
1056            ids[index]
1057                .split_once('-')
1058                .map_or(0, |(_, digits)| digits.len())
1059                >= MIN_PREFIX_DIGITS
1060        })
1061        .collect();
1062    if usable.is_empty() {
1063        return Ok(found);
1064    }
1065    let prefixes: Vec<&str> = usable.iter().map(|&index| ids[index]).collect();
1066    for mailbox in crate::mailbox::all_mailboxes(root) {
1067        for (matched, stored) in mailbox.find_prefixes(&prefixes)? {
1068            let list = &mut found[usable[matched]];
1069            if !list
1070                .iter()
1071                .any(|(_, known)| known.envelope.id == stored.envelope.id)
1072            {
1073                list.push((mailbox.clone(), stored));
1074            }
1075        }
1076    }
1077    Ok(found)
1078}