From d96bc041a7c2dc006d21693afd02cd79c575e2e7 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 29 Aug 2026 04:54:11 -0400 Subject: [PATCH] Say how big a session is before it is imported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import list reported a line count, which is the wrong axis: these transcripts embed screenshots as base64, so one line can be a megabyte. On this machine a 69 MB session has 3,427 lines while a 44 MB one has 6,792 — the number on the row said nothing about what continuing the session would cost, and size is the only thing there that predicts it. The session behind the 2026-08-29 incident was 65 MB across 13,000 lines, a line count that looks unremarkable. Shown beside the line count rather than instead of it, since a short file of long lines is exactly the expensive case. Not warned about and not marked: importing a large session is a choice somebody is entitled to make, and flagging it would be the interface nagging about a decision already taken. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 --- AGENTS.md | 7 +++++++ .../src/main/kotlin/com/example/aiapp/Api.kt | 9 +++++++++ .../kotlin/com/example/aiapp/ImportScreen.kt | 13 ++++++++++++ server/src/session/import.rs | 20 +++++++++++++++++-- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 58214b8..e136a76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -272,6 +272,13 @@ day: - Local only: an ssh session's child dies with its connection, so it takes the ordinary `--resume` path. +The import list reports each session's **size as well as its line count**, +because the two disagree in the way that matters: these transcripts embed +screenshots as base64, so one line can be a megabyte. On this machine a +69 MB session has 3,427 lines and a 44 MB one has 6,792 — nothing about a +line count tells you what continuing a session will cost. Shown, not warned +about; importing a large session is a choice somebody is entitled to make. + **Never import a Claude Code session that is open in a terminal.** The app refuses it now — it reads `~/.claude/sessions/.json`, which Claude Code keeps for every live session, and checks the pid's start time so a diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 62d14b0..826fb6c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -196,6 +196,14 @@ data class Importable( val title: String, val modified: Double, val lines: Int, + /** + * Size of the session file in bytes. + * + * Worth a place on the row because it is the only thing there that predicts what continuing the + * session costs, and the line count does not: these transcripts embed screenshots as base64, so + * a single line can be a megabyte. + */ + val bytes: Long, /** Whether [title] is a name somebody chose rather than the last thing said in the session. */ val named: Boolean, /** @@ -217,6 +225,7 @@ fun fetchImportable(settings: ServerSettings, setup: String): List = title = session.optString("title"), modified = session.optDouble("modified", 0.0), lines = session.optInt("lines", 0), + bytes = session.optLong("bytes", 0L), // Absent means an older backend that cannot answer, which is exactly what // "unknown" says -- so the default is the honest one rather than "no". inUse = session.optString("inUse", "unknown"), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index e466577..f334f77 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -304,6 +304,15 @@ private fun ImportableList( } } +/** A byte count at the coarsest unit that still says something, so rows stay comparable. */ +private fun humanSize(bytes: Long): String? = + when { + bytes <= 0L -> null + bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB" + bytes >= 1_000L -> "${bytes / 1_000L} kB" + else -> "$bytes B" + } + /** The second line of a row: what this session is, in the order it is worth knowing. */ private fun detailOf(session: Importable, importing: String?): String = listOfNotNull( @@ -320,6 +329,10 @@ private fun detailOf(session: Importable, importing: String?): String = // session, the other is only what happened last in it. if (session.named) "named" else null, "${session.lines} lines", + // Beside the line count rather than instead of it: the two disagree usefully. A + // short file of long lines is a session full of screenshots, and that is the one + // that is expensive to carry on with. + humanSize(session.bytes), // The tail, not the head: a path is identified by where it ends, and these all share // a long prefix. session.cwd diff --git a/server/src/session/import.rs b/server/src/session/import.rs index 104cffa..23c9ade 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -67,6 +67,19 @@ pub struct Importable { /// Epoch seconds, for ordering by "what I was last doing". pub modified: f64, pub lines: usize, + /// Size of the file, in bytes. + /// + /// Reported because it is the only thing on a row that predicts what + /// continuing the session will cost, and lines do not: these + /// transcripts embed screenshots as base64, so one line can be a + /// megabyte. The session behind the 2026-08-29 incident was 65 MB + /// across 13,000 lines, which is a line count that looks unremarkable. + /// + /// Shown rather than warned about. Importing a large session is a + /// choice somebody is entitled to make, and marking it would be the + /// interface nagging about a decision already taken -- but they should + /// be able to see what they are taking on. + pub bytes: u64, /// Whether [`title`](Self::title) is a name somebody chose rather than /// something read out of the conversation. Sorted on, and worth the /// reader knowing: a name is a claim about what a session *is*, and a @@ -151,7 +164,8 @@ if [ -d "$HOME/.claude/sessions" ]; then fi for f in "$HOME"/.claude/projects/*/*.jsonl; do [ -f "$f" ] || continue - printf '%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" "$(wc -l < "$f")" "$f" + printf '%s\t%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" \ + "$(wc -l < "$f")" "$(stat -c %s "$f" 2>/dev/null || echo 0)" "$f" grep '"type":"custom-title"' "$f" 2>/dev/null | tail -1 | tr '\n' '\037' grep '"type":"user"' "$f" 2>/dev/null | grep -v '"tool_use_id"' | tail -12 | tr '\n' '\037' printf '\n' @@ -190,9 +204,10 @@ done /// One line of [`list`]'s output, or nothing if it is not one. fn parse_row(line: &str) -> Option { - let mut fields = line.splitn(4, '\t'); + let mut fields = line.splitn(5, '\t'); let modified: f64 = fields.next()?.trim().parse().ok()?; let lines: usize = fields.next()?.trim().parse().ok()?; + let bytes: u64 = fields.next()?.trim().parse().ok()?; let path = fields.next()?.to_string(); let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string(); @@ -238,6 +253,7 @@ fn parse_row(line: &str) -> Option { .unwrap_or_else(|| "(no messages)".to_string()), modified, lines, + bytes, path, }) }