EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / AI / Repairing a Claude Code Session .jsonl File Too Large to Open at 38 MB
AI

Repairing a Claude Code Session .jsonl File Too Large to Open at 38 MB

by Ton-Or ·8 Aug 2026·8 min read
Repairing a Claude Code Session .jsonl File Too Large to Open at 38 MB

AI chat history is just a plain log file that needs maintenance like any other log. If I keep letting it grow without ever wrapping up, one day it will stop opening. The repair principles apply to any append-only file, not just Claude Code.

I opened an old workspace and found that the session history would not open. The UI just sat there frozen, and trying /resume left me waiting far too long. Only when I checked the file did I realize that a single session I had kept working in for 8 days had grown to 38.61 MB with 11,537 records — the program had to load that entire file into memory and parse every line of JSON before it could display anything. So I sat down and wrote about a hundred lines of Python to shrink it instead of deleting the whole session.

The result was 17.41 MB, a 55% reduction, with every line of both user and assistant messages intact, in the original order, and not a single orphan. I have kept the script permanently because I am very likely to need it again.

38.61 MBSize before repair
17.41 MBAfter repair, down 55%
11,537records in the file
8,171message uuid values, all preserved

This session spanned 8 days and contained 5,218 assistant turns. The reason it grew so large was simple — I kept working in one session without wrapping up between work periods, and every tool call that read a large file, every command that produced long output, and every screenshot was appended to that same file.

Understand the .jsonl File Before Touching It

The session file uses JSON Lines: one line is one JSON object, which is one record. The record types I found fell into two main groups.

Message records (type: "user" or "assistant") contain uuid, parentUuid, timestamp, and message.content[], which can hold several kinds of blocks — text, thinking, tool_use, tool_result, image — and some lines also have a separate toolUseResult at the top level.

The other type is file-history-snapshot, which stores snapshots of file contents for the /rewind feature of a running session. The important point is that it is not part of the conversation's uuid chain.

Keep this chain intactrecord Auuid->record BparentUuid = A->record CparentUuid = B->Delete BC becomes an orphan

The transcript is reconstructed by following the chain from parentUuid to uuid in file line order. If I delete a record in the middle of the chain, its child points to a parent that no longer exists, leaving a gap in the conversation or preventing the entire session from loading. Another pair that must stay together is the assistant's tool_use and the user's tool_result, which must always have matching tool_use_id values. Deleting either side causes an unmatched content block error as soon as I resume.

The one rule to follow

Shrink the contents of records, but never reduce the number of records in the chain — except for record types that are outside the chain entirely, such as file-history-snapshot, which can be discarded whole. This rule is not specific to Claude Code; it applies to any append-only file whose records form a chain.

What Can Be Trimmed and What Cannot

Safe to trim for reading

Only these 4 things

  • file-history-snapshot Discard the entire record — this is /rewind data for a running session; nobody rewinds a finished session (414 records = 5.67 MB)
  • thinking blocks with empty thinking Only an encrypted signature remains, which cannot be read and is not displayed (1,658 blocks = 7.09 MB)
  • base64 images Replace with a 1x1 PNG, reducing the payload to roughly a hundred bytes while keeping an image block that conforms to the schema (33 images = 2.78 MB)
  • tool_result / toolUseResult Clip at 1,200 characters and append a marker indicating how much was removed
Do not touch

Once lost, it is gone

  • message.content[].text On both the user and assistant sides — this is the actual conversation
  • uuid / parentUuid / timestamp And the order of lines in the file
  • tool_use.input Contains the commands issued, making it the most valuable history after the messages themselves (my script clips this at 1,200 characters too; anyone who wants to keep it in full can adjust this)
  • thinking blocks with actual content They are readable and must not be trimmed

Adding those up gives snapshot 5.67 + signature 7.09 + images 2.78 = 15.54 MB, all material nobody goes back to read. The rest is long tool output; the actual conversation makes up a very small portion of the file.

Before: 38.61 MB5.67 MB7.09 MB2.78Remaining: 23.07 MBsnapshotempty thinkingimageslong tool output and other dataAfter: 17.41 MB17.41 MBDown 55%, all 8,171 uuid values intact
The red sections are removable data totaling 15.54 MB. Bar lengths are proportional to actual sizes.

The Repair Script

I keep it at ~/.claude/tools/repair_session_jsonl.py. Here is the full script.

"""Slim a bloated Claude Code session .jsonl so it opens again.

KEEPS every user/assistant message record, its visible text, tool_use inputs,
uuid/parentUuid chain, timestamps -> the transcript still reads end to end.

SHRINKS the parts that carry no readable value:
  1. file-history-snapshot records   (only power /rewind on a live session)
  2. thinking blocks whose `thinking` text is EMPTY -> dropped whole
     (all payload is the opaque encrypted `signature`; nothing to read)
  3. base64 image blobs              -> 1x1 png placeholder
  4. tool_result / toolUseResult     -> clipped to CAP chars with a marker

Usage: repair_session_jsonl.py <in.jsonl> <out.jsonl> [--cap N]
"""
import json, sys, io, collections

IN, OUT = sys.argv[1], sys.argv[2]
CAP = int(sys.argv[sys.argv.index("--cap") + 1]) if "--cap" in sys.argv else 1200

PLACEHOLDER = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP"
               "4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC")
MARK = "\n[... clipped by session-repair: %d chars ...]"
stats = collections.Counter()


def clip(s):
    if isinstance(s, str) and len(s) > CAP:
        stats["clipped_chars"] += len(s) - CAP
        return s[:CAP] + (MARK % (len(s) - CAP))
    return s


def strip_images(o):
    if isinstance(o, dict):
        if o.get("type") == "image" and isinstance(o.get("source"), dict):
            src = o["source"]
            d = src.get("data")
            if isinstance(d, str) and len(d) > len(PLACEHOLDER):
                stats["image_chars"] += len(d) - len(PLACEHOLDER)
                stats["images"] += 1
                src["data"] = PLACEHOLDER
                src["media_type"] = "image/png"
            return o
        for v in o.values():
            strip_images(v)
    elif isinstance(o, list):
        for v in o:
            strip_images(v)
    return o


def clip_deep(o):
    if isinstance(o, dict):
        return {k: clip_deep(v) for k, v in o.items()}
    if isinstance(o, list):
        return [clip_deep(v) for v in o]
    return clip(o)


kept = dropped_snap = dropped_think = 0
with io.open(IN, encoding="utf-8") as fin, \
     io.open(OUT, "w", encoding="utf-8", newline="\n") as fout:
    for line in fin:
        if not line.strip():
            continue
        try:
            o = json.loads(line)
        except Exception:
            stats["unparsed_passthrough"] += 1
            fout.write(line if line.endswith("\n") else line + "\n")
            kept += 1
            continue

        if o.get("type") == "file-history-snapshot":
            dropped_snap += 1
            stats["snapshot_chars"] += len(line)
            continue

        msg = o.get("message")
        if isinstance(msg, dict) and isinstance(msg.get("content"), list):
            new = []
            for b in msg["content"]:
                if (isinstance(b, dict) and b.get("type") == "thinking"
                        and not (b.get("thinking") or "").strip()):
                    dropped_think += 1
                    stats["signature_chars"] += len(b.get("signature") or "")
                    continue
                # clip only tool payloads; leave prose untouched
                if isinstance(b, dict) and b.get("type") in ("tool_result", "tool_use"):
                    b = clip_deep(b)
                new.append(b)
            # never leave an assistant turn with an empty content array
            msg["content"] = new if new else [{"type": "text", "text": ""}]

        strip_images(o.get("message"))
        strip_images(o.get("attachment"))
        if "toolUseResult" in o:
            o["toolUseResult"] = clip_deep(strip_images(o["toolUseResult"]))

        fout.write(json.dumps(o, ensure_ascii=False) + "\n")
        kept += 1

print("kept %d lines | dropped %d snapshots, %d empty thinking blocks"
      % (kept, dropped_snap, dropped_think))
for k, v in sorted(stats.items()):
    extra = "  (%.2f MB)" % (v / 1048576) if k.endswith("chars") else ""
    print("  %-20s %10d%s" % (k, v, extra))

Lines Worth a Close Look

The script streams the file line by line instead of loading the whole thing into RAM, so even a 40 MB file uses very little memory. But the details that really matter are these small ones.

except Exception: passthrough — I write any line that fails JSON parsing back unchanged instead of discarding it. Better to be safe than to assume it does not matter.

msg["content"] = new if new else [{"type":"text","text":""}] — an assistant turn with an empty content array violates the schema. It causes a problem as soon as I resume and send it back to the API, so I always insert an empty text block instead.

clip_deep is called only on tool_result and tool_use blocks and the toolUseResult key. It leaves text blocks untouched — this is crucial. If I accidentally clip every string longer than CAP across the whole file, long user messages and long answers get cut too, needlessly losing useful content.

ensure_ascii=False is essential; otherwise, Thai becomes \uXXXX and the file grows back to nearly its original size. And newline="\n" prevents CRLF line endings when writing on Windows.

strip_images walks recursively through both message and attachment because pasted images and screenshots live in different places. The placeholder is a real 1x1 PNG that can be decoded from base64 and opened, and I must also force media_type: image/png because the original might be jpeg.

The Actual Workflow

  1. Always close Claude Code first If a process still holds the file open, it will happily overwrite it again
  2. Back up the full file first. 38 MB is not large; keep it until I have confirmed that the old session actually opens, then delete it
  3. Run the script to produce a new file without overwriting the original
  4. Check before swapping — the uuid count must stay the same, and orphan must be 0
  5. Only after the checks pass, replace the original with the repaired file and reopen the program
# 1. backup ตัวเต็ม
cp ~/.claude/projects/<workspace>/<session-uuid>.jsonl \
   ~/.claude/backups/<session-uuid>.jsonl.$(date +%Y%m%d).bak

# 2. ซ่อมออกเป็นไฟล์ใหม่
python repair_session_jsonl.py <session-uuid>.jsonl <session-uuid>.fixed.jsonl --cap 1200

# 3. เทียบขนาดก่อนสลับ
ls -lh <session-uuid>.jsonl <session-uuid>.fixed.jsonl

My check before swapping is just this short snippet. It counts uuid values across the file, then checks whether any parentUuid points to a uuid that does not exist.

import json, sys
uu, orphan = set(), 0
rows = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
for o in rows:
    if o.get("uuid"): uu.add(o["uuid"])
for o in rows:
    p = o.get("parentUuid")
    if p and p not in uu: orphan += 1
print(len(rows), "records |", len(uu), "uuid |", orphan, "orphan")

Run it on the original and repaired files and compare the results. The required result is 8,171 uuid values in both files, and orphan = 0. If the uuid counts differ or even a single orphan appears, do not overwrite the original under any circumstances. Go back and review the trimming conditions.

Three costly mistakes

1) Removing file-history-snapshot permanently removes /rewind for that session. This is acceptable if the session has ended, but never do it to a session with work still in progress · 2) Never remove thinking blocks with actual content. The condition must be strictly "thinking is empty after strip". Using only type == thinking as the condition will remove all readable content too · 3) Never delete a record containing tool_use without deleting its matching tool_result, or vice versa — resuming will definitely fail

Preventing It from Growing Again

My conclusion is that the real problem is not the file size, but my habit of keeping a whole week's work in one session. In this case, that meant 8 days and 5,218 assistant turns in a single file.

I have switched to separate sessions for separate tasks, saving a summary to memory before finishing each work period. Next time, I open a new session and continue from that summary. This gives me two benefits: smaller files and more concise context, because an overly long session does not just create a large file; it carries irrelevant old material along throughout the work.

Beyond that, I watch for actions that generate large payloads each time — reading entire large files, commands that produce long output, and taking screenshots in quick succession (just 33 images already totaled 2.78 MB). I also make a habit of checking the size occasionally. Once a session file exceeds around 15-20 MB, it is time to start thinking about wrapping up.

ls -S ~/.claude/projects/*/*.jsonl | head -5 | xargs ls -lh

Feel free to set --cap higher than 1,200 to retain more tool output. I chose this number because I think hardly anyone goes back to read beyond that anyway. But there is one principle I really want readers to take away and use — shrink the contents of records, but do not reduce the number of records in the chain. If I have left anything out or made a typo, I will come back and fix it later.

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first