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    read_claude(&transcript(homes, address)?).ok()
559}
560
561/// The newest of `items` (time, id), sorted by time, sent before `at` (or at
562/// it, when `inclusive`).
563fn newest_before(items: &[(u64, String)], at: u64, inclusive: bool) -> Option<String> {
564    items
565        .iter()
566        .rev()
567        .find(|(when, _)| if inclusive { *when <= at } else { *when < at })
568        .map(|(_, id)| id.clone())
569}
570
571/// Each answer the session gave, as (when, id), oldest first.
572fn answer_times(address: &MailAddress, mail: &TranscriptMail) -> Vec<(u64, String)> {
573    let mut times: Vec<(u64, String)> = mail
574        .answers
575        .iter()
576        .map(|answer| (answer.at_ms, answer_id(address, &answer.native_id)))
577        .collect();
578    times.sort();
579    times
580}
581
582/// File `envelope` unless it is filed as it stands; a record an earlier
583/// reading filed differently (without its inferred reply) is filed again.
584fn file_derived(
585    mailbox: &Mailbox,
586    filed: &HashMap<&str, &StoredEnvelope>,
587    envelope: Envelope,
588) -> std::io::Result<bool> {
589    if let Some(stored) = filed.get(envelope.id.as_str()) {
590        if stored.envelope.in_reply_to == envelope.in_reply_to {
591            return Ok(false);
592        }
593        std::fs::remove_file(&stored.path).ok();
594    }
595    mailbox.file_read(&envelope)?;
596    Ok(true)
597}
598
599/// File every line the user of `mailbox`'s session typed that is not filed
600/// yet, reading the transcript as `mail`. Returns how many were filed now.
601pub fn file_typed_lines(mailbox: &Mailbox, mail: &TranscriptMail) -> std::io::Result<usize> {
602    let address = mailbox.address();
603    let filed = mailbox.list()?;
604    let by_id: HashMap<&str, &StoredEnvelope> = filed
605        .iter()
606        .map(|stored| (stored.envelope.id.as_str(), stored))
607        .collect();
608    // A line a person sends answers the session's newest answer before it:
609    // the reply is inferred, as a mail client threads a reply by its subject.
610    let answers = answer_times(address, mail);
611    // What supercode typed into the pane is filed already, under its own id:
612    // each such turn accounts for one typed line with its words.
613    let mut typed_by_supercode: HashMap<&str, usize> = HashMap::new();
614    for stored in &filed {
615        if stored.envelope.kind == MailKind::User
616            && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
617        {
618            *typed_by_supercode
619                .entry(stored.envelope.body.trim())
620                .or_default() += 1;
621        }
622    }
623    let user = user_address(&address.machine)?;
624    // A typed line's record is the transcript's reading: one the reading no
625    // longer yields (a queued line that landed as a scheduled prompt's or a
626    // session's, or one an older reading took for the user's) is taken back.
627    let current: HashSet<String> = mail
628        .typed
629        .iter()
630        .map(|line| typed_line_id(address, line.sent_at_ms, &line.text))
631        .collect();
632    for stored in &filed {
633        if stored.envelope.id.starts_with(TYPED_ID_PREFIX) && !current.contains(&stored.envelope.id)
634        {
635            std::fs::remove_file(&stored.path).ok();
636        }
637    }
638    let mut count = 0;
639    for line in &mail.typed {
640        let id = typed_line_id(address, line.sent_at_ms, &line.text);
641        if !by_id.contains_key(id.as_str()) {
642            if let Some(left) = typed_by_supercode.get_mut(line.text.trim()) {
643                if *left > 0 {
644                    *left -= 1;
645                    continue;
646                }
647            }
648        }
649        let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
650        count += usize::from(file_derived(
651            mailbox,
652            &by_id,
653            Envelope {
654                id,
655                created_at_ms: line.sent_at_ms,
656                from: user.clone(),
657                from_name: format!("user@{}", address.machine),
658                kind: MailKind::User,
659                reply_via: ReplyVia::None,
660                in_reply_to_inferred: in_reply_to.is_some(),
661                in_reply_to,
662                thread: None,
663                native_from: None,
664                body: line.text.clone(),
665            },
666        )?);
667    }
668    for line in &mail.channel {
669        let id = channel_line_id(address, &line.header);
670        let in_reply_to = newest_before(&answers, line.sent_at_ms, false);
671        count += usize::from(file_derived(
672            mailbox,
673            &by_id,
674            Envelope {
675                id,
676                created_at_ms: line.sent_at_ms,
677                from: MailAddress::new(address.machine.clone(), "operator", "channel")
678                    .map_err(|error| std::io::Error::other(error.0))?,
679                from_name: line.from.clone(),
680                kind: MailKind::Channel,
681                reply_via: ReplyVia::None,
682                in_reply_to_inferred: in_reply_to.is_some(),
683                in_reply_to,
684                thread: None,
685                native_from: None,
686                body: line.text.clone(),
687            },
688        )?);
689    }
690    Ok(count)
691}
692
693/// File every answer the session at `address` gave its user that is not filed
694/// yet, in the user's mailbox under `root`, from the session (`name` is what
695/// the user knows it by). Returns how many were filed now.
696pub fn file_answers(
697    root: &Path,
698    address: &MailAddress,
699    name: &str,
700    mail: &TranscriptMail,
701) -> std::io::Result<usize> {
702    let mailbox = Mailbox::open(root, &user_address(&address.machine)?)?;
703    let listed = mailbox.list()?;
704    let mut filed: HashMap<&str, &StoredEnvelope> = HashMap::new();
705    for stored in &listed {
706        // An answer an earlier reading filed as a session's mail is filed again as an answer.
707        if stored.envelope.id.starts_with(ANSWER_ID_PREFIX)
708            && stored.envelope.kind != MailKind::Answer
709        {
710            std::fs::remove_file(&stored.path).ok();
711            continue;
712        }
713        filed.insert(stored.envelope.id.as_str(), stored);
714    }
715    // An answer answers the newest line a person sent the session before it: a
716    // line typed or spoken to it, a Room member's turn, a channel line.
717    let mut people: Vec<(u64, String)> = mail
718        .typed
719        .iter()
720        .map(|line| {
721            (
722                line.sent_at_ms,
723                typed_line_id(address, line.sent_at_ms, &line.text),
724            )
725        })
726        .chain(
727            mail.channel
728                .iter()
729                .map(|line| (line.sent_at_ms, channel_line_id(address, &line.header))),
730        )
731        .chain(
732            Mailbox::open(root, address)?
733                .list()?
734                .into_iter()
735                .filter(|stored| {
736                    stored.envelope.kind == MailKind::User
737                        && !stored.envelope.id.starts_with(TYPED_ID_PREFIX)
738                })
739                .map(|stored| (stored.envelope.created_at_ms, stored.envelope.id)),
740        )
741        .collect();
742    people.sort();
743    let mut count = 0;
744    for answer in &mail.answers {
745        let id = answer_id(address, &answer.native_id);
746        let in_reply_to = newest_before(&people, answer.at_ms, true);
747        count += usize::from(file_derived(
748            &mailbox,
749            &filed,
750            Envelope {
751                id,
752                created_at_ms: answer.at_ms,
753                from: address.clone(),
754                from_name: name.to_string(),
755                kind: MailKind::Answer,
756                reply_via: ReplyVia::None,
757                in_reply_to_inferred: in_reply_to.is_some(),
758                in_reply_to,
759                thread: None,
760                native_from: None,
761                body: answer.text.clone(),
762            },
763        )?);
764    }
765    Ok(count)
766}
767
768/// Everything the session at `address` sent that is filed on this machine
769/// under `root`: its messages to other sessions and its answers to its user,
770/// each with the mailbox it is filed in, oldest first.
771pub fn sent_by(root: &Path, address: &MailAddress) -> Vec<(MailAddress, StoredEnvelope)> {
772    let mut sent: Vec<(MailAddress, StoredEnvelope)> = crate::mailbox::all_mailboxes(root)
773        .into_iter()
774        .filter(|mailbox| mailbox.address() != address)
775        .flat_map(|mailbox| {
776            let to = mailbox.address().clone();
777            mailbox
778                .list()
779                .unwrap_or_default()
780                .into_iter()
781                .filter(|stored| &stored.envelope.from == address)
782                .map(move |stored| (to.clone(), stored))
783        })
784        .collect();
785    sent.sort_by_key(|(_, stored)| stored.envelope.created_at_ms);
786    sent
787}
788
789/// Where each of `stored` (the mailbox of the session at `address`) stands,
790/// by id, as the transcript reading `mail` shows it (`None`: unreadable).
791pub fn deliveries(
792    address: &MailAddress,
793    stored: &[StoredEnvelope],
794    mail: Option<&TranscriptMail>,
795) -> BTreeMap<String, Delivery> {
796    let Some(mail) = mail else {
797        return stored
798            .iter()
799            .map(|stored| (stored.envelope.id.clone(), Delivery::Unknown))
800            .collect();
801    };
802    let typed: HashMap<String, &TypedLine> = mail
803        .typed
804        .iter()
805        .map(|line| (typed_line_id(address, line.sent_at_ms, &line.text), line))
806        .collect();
807    let channel: HashMap<String, &ChannelLine> = mail
808        .channel
809        .iter()
810        .map(|line| (channel_line_id(address, &line.header), line))
811        .collect();
812    // A turn supercode typed into the pane reaches the model as a typed line
813    // with its words, the first one sent after it was filed.
814    let mut used: HashSet<usize> = HashSet::new();
815    let mut result = BTreeMap::new();
816    for stored in stored {
817        let envelope = &stored.envelope;
818        if typed.get(&envelope.id).is_some_and(|line| line.withdrawn) {
819            result.insert(envelope.id.clone(), Delivery::Withdrawn);
820            continue;
821        }
822        let at = if let Some(line) = typed.get(&envelope.id) {
823            line.delivered_at_ms
824        } else if let Some(line) = channel.get(&envelope.id) {
825            line.delivered_at_ms
826        } else if let Some(at) = mail
827            .delivered
828            .get(&envelope.id)
829            .or_else(|| mail.delivered.get(crate::mailbox::short_id(&envelope.id)))
830        {
831            Some(*at)
832        } else if envelope.kind == MailKind::User {
833            mail.typed
834                .iter()
835                .enumerate()
836                .find(|(index, line)| {
837                    !used.contains(index)
838                        && line.sent_at_ms + 1_000 >= envelope.created_at_ms
839                        && line.text.trim() == envelope.body.trim()
840                })
841                .and_then(|(index, line)| {
842                    used.insert(index);
843                    line.delivered_at_ms
844                })
845        } else {
846            None
847        };
848        result.insert(
849            envelope.id.clone(),
850            match at {
851                Some(at_ms) => Delivery::Delivered { at_ms },
852                None => Delivery::Sent,
853            },
854        );
855    }
856    result
857}
858
859/// Fewest hex digits after the kind (`m-`, `u-`, `a-`) a prefix may give.
860pub const MIN_PREFIX_DIGITS: usize = 4;
861
862/// The messages filed on this machine under `root` whose id is `id` or
863/// starts with it, like git's short hashes: the mailbox holding each, and the
864/// envelope. A prefix shorter than the kind and [`MIN_PREFIX_DIGITS`] digits
865/// matches nothing; more than one match means the prefix is ambiguous.
866pub fn find_messages(root: &Path, id: &str) -> std::io::Result<Vec<(Mailbox, StoredEnvelope)>> {
867    let digits = id.split_once('-').map_or(0, |(_, digits)| digits.len());
868    if digits < MIN_PREFIX_DIGITS {
869        return Ok(Vec::new());
870    }
871    let mut found: Vec<(Mailbox, StoredEnvelope)> = Vec::new();
872    for mailbox in crate::mailbox::all_mailboxes(root) {
873        for stored in mailbox.find_prefix(id)? {
874            if !found
875                .iter()
876                .any(|(_, known)| known.envelope.id == stored.envelope.id)
877            {
878                found.push((mailbox.clone(), stored));
879            }
880        }
881    }
882    Ok(found)
883}