Say how big a session is before it is imported
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
This commit is contained in:
1 parent
362d436d4f
commit
d96bc041a7
4 files changed
+47
-2
No files matched your search
@@ -272,6 +272,13 @@ day:
|
|||||||
- Local only: an ssh session's child dies with its connection, so it takes
|
- Local only: an ssh session's child dies with its connection, so it takes
|
||||||
the ordinary `--resume` path.
|
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
|
**Never import a Claude Code session that is open in a terminal.** The app
|
||||||
refuses it now — it reads `~/.claude/sessions/<pid>.json`, which Claude
|
refuses it now — it reads `~/.claude/sessions/<pid>.json`, which Claude
|
||||||
Code keeps for every live session, and checks the pid's start time so a
|
Code keeps for every live session, and checks the pid's start time so a
|
||||||
|
|||||||
@@ -196,6 +196,14 @@ data class Importable(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val modified: Double,
|
val modified: Double,
|
||||||
val lines: Int,
|
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. */
|
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
|
||||||
val named: Boolean,
|
val named: Boolean,
|
||||||
/**
|
/**
|
||||||
@@ -217,6 +225,7 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
|||||||
title = session.optString("title"),
|
title = session.optString("title"),
|
||||||
modified = session.optDouble("modified", 0.0),
|
modified = session.optDouble("modified", 0.0),
|
||||||
lines = session.optInt("lines", 0),
|
lines = session.optInt("lines", 0),
|
||||||
|
bytes = session.optLong("bytes", 0L),
|
||||||
// Absent means an older backend that cannot answer, which is exactly what
|
// Absent means an older backend that cannot answer, which is exactly what
|
||||||
// "unknown" says -- so the default is the honest one rather than "no".
|
// "unknown" says -- so the default is the honest one rather than "no".
|
||||||
inUse = session.optString("inUse", "unknown"),
|
inUse = session.optString("inUse", "unknown"),
|
||||||
|
|||||||
@@ -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. */
|
/** 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 =
|
private fun detailOf(session: Importable, importing: String?): String =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
@@ -320,6 +329,10 @@ private fun detailOf(session: Importable, importing: String?): String =
|
|||||||
// session, the other is only what happened last in it.
|
// session, the other is only what happened last in it.
|
||||||
if (session.named) "named" else null,
|
if (session.named) "named" else null,
|
||||||
"${session.lines} lines",
|
"${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
|
// The tail, not the head: a path is identified by where it ends, and these all share
|
||||||
// a long prefix.
|
// a long prefix.
|
||||||
session.cwd
|
session.cwd
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ pub struct Importable {
|
|||||||
/// Epoch seconds, for ordering by "what I was last doing".
|
/// Epoch seconds, for ordering by "what I was last doing".
|
||||||
pub modified: f64,
|
pub modified: f64,
|
||||||
pub lines: usize,
|
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
|
/// Whether [`title`](Self::title) is a name somebody chose rather than
|
||||||
/// something read out of the conversation. Sorted on, and worth the
|
/// 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
|
/// 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
|
fi
|
||||||
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||||
[ -f "$f" ] || continue
|
[ -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":"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'
|
grep '"type":"user"' "$f" 2>/dev/null | grep -v '"tool_use_id"' | tail -12 | tr '\n' '\037'
|
||||||
printf '\n'
|
printf '\n'
|
||||||
@@ -190,9 +204,10 @@ done
|
|||||||
|
|
||||||
/// One line of [`list`]'s output, or nothing if it is not one.
|
/// One line of [`list`]'s output, or nothing if it is not one.
|
||||||
fn parse_row(line: &str) -> Option<Importable> {
|
fn parse_row(line: &str) -> Option<Importable> {
|
||||||
let mut fields = line.splitn(4, '\t');
|
let mut fields = line.splitn(5, '\t');
|
||||||
let modified: f64 = fields.next()?.trim().parse().ok()?;
|
let modified: f64 = fields.next()?.trim().parse().ok()?;
|
||||||
let lines: usize = 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 path = fields.next()?.to_string();
|
||||||
let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string();
|
let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string();
|
||||||
|
|
||||||
@@ -238,6 +253,7 @@ fn parse_row(line: &str) -> Option<Importable> {
|
|||||||
.unwrap_or_else(|| "(no messages)".to_string()),
|
.unwrap_or_else(|| "(no messages)".to_string()),
|
||||||
modified,
|
modified,
|
||||||
lines,
|
lines,
|
||||||
|
bytes,
|
||||||
path,
|
path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user