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