Show a replayed session's images instead of dropping them

An imported session showed no screenshots. `text_of` kept only `text`
blocks, so every image in the replayed tail was silently discarded -- while
the *live* translator has always saved them into the session's `files/` and
referenced them. Two readings of the same records, and the one used for
history was the lesser.

`save_image` moves out of `Translator` to a free function both paths call,
since the naming scheme for that directory should exist once. `events_from`
now takes the session directory to write into, which means the conversion
has to happen where that directory exists -- so `Seed` carries the raw
JSONL and `launch` turns it into events, rather than `routes` doing it
before the session is created.

Costs nothing in tokens, which is the point worth recording: this writes
into ai-app's own session directory and the phone fetches a reference only
when it draws one. Nothing here is ever written to the CLI's stdin -- it
reads its own session file, and the only things this app sends it are typed
messages, control requests and `/compact`.

Verified against the 133 MB session behind the 2026-08-29 incident: 45
images in the replayed tail, written as real PNGs and served over the files
route, with the transcript itself staying at 756 KB of references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 05:01:48 -04:00
1 parent d96bc041a7
commit d7c692a4ec
6 files changed
+148 -50

No files matched your search

+10 -6
View File
@@ -831,7 +831,7 @@ fn spawn_import_sync(
if lines <= cursor.lines {
continue;
}
match import::replay_after(&transport, &cursor.path, cursor.lines).await {
match import::replay_after(&transport, &cursor.path, cursor.lines, &dir).await {
Ok(events) => {
tracing::info!(
"{} grew by {} lines with nothing from here; replaying {} events",
@@ -866,10 +866,14 @@ pub struct Seed {
/// Where that session's file is and how much of it has been shown, so
/// the session can keep itself up to date afterwards.
pub cursor: import::Cursor,
/// Replayed into the transcript so the phone shows the conversation it
/// is joining. The CLI reads the real file itself, so this is what the
/// reader sees rather than what the model is given.
pub events: Vec<Event>,
/// The tail of that session's file, as the raw JSONL.
///
/// Turned into events in `launch`, not before, because doing so writes
/// out the images the records carry and that needs the session
/// directory to write them into -- which does not exist until the
/// session does. The CLI reads the real file itself, so this only ever
/// decides what the *reader* sees.
pub records: String,
}
fn launch(
@@ -890,7 +894,7 @@ fn launch(
claude::write_resume_token(&dir, &seed.resume);
import::write_cursor(&dir, &seed.cursor);
let at = now();
for event in seed.events {
for event in import::events_from(&seed.records, &dir) {
transcript.append(event, at)?;
}
}