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