Last active
September 28, 2026 08:52
-
-
Save Londeren/beaf6bcc59b5bee51f8ac06df1991669 to your computer and use it in GitHub Desktop.
Export Claude Code session .jsonl transcripts to readable markdown (Stop hook / CLI). Config via env vars: TRANSCRIPT_INBOX, TRANSCRIPT_USER_LABEL, TRANSCRIPT_ASSISTANT_LABEL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Export a Claude Code session .jsonl transcript to readable markdown. | |
| Synced with gist (canonical shared copy, keep identical): | |
| https://gist.github.com/Londeren/beaf6bcc59b5bee51f8ac06df1991669 | |
| After editing this file, push the update: | |
| gh gist edit beaf6bcc59b5bee51f8ac06df1991669 --filename export-transcript.py <path to this file> | |
| Keeps only the user's messages and Claude's text replies. A slash command | |
| the user typed (a skill invocation like `/glavred <text>`) is a user | |
| message too: it is rendered as `/name` + its arguments. | |
| Drops: thinking, tool_use, tool_result, sidechain agents, harness service | |
| tags (<system-reminder>, <task-notification>, the IDE context blocks | |
| <ide_opened_file> / <ide_selection> and the like), local built-in | |
| commands (/model, /cost ...) — the harness marks those with a preceding | |
| <local-command-caveat> entry, and their output arrives in | |
| <local-command-stdout> — and two kinds of entries the harness writes in a | |
| speaker's role although nobody said them: the context-compaction summary | |
| ("This session is being continued from a previous conversation...", a | |
| user-role entry flagged isCompactSummary) and synthetic error replies | |
| ("API Error: ...", "You've hit your session limit ...", "Failed to | |
| authenticate ..." — assistant-role entries flagged isApiErrorMessage). | |
| Modes: | |
| Hook: export-transcript.py --hook (reads hook JSON from stdin) | |
| Manual: export-transcript.py INPUT.jsonl [-o OUTPUT.md] | |
| export-transcript.py INPUT.jsonl (prints to stdout) | |
| Hook mode details: | |
| - The inbox root is CLAUDE_PROJECT_DIR when set (falls back to the hook's | |
| cwd). The session cwd can wander into subfolders mid-session; resolving | |
| against the project root keeps the inbox in one place. | |
| - Claude Code writes the transcript .jsonl asynchronously (documented in | |
| hooks.md): at Stop time the final assistant message(s) of the turn are | |
| often NOT yet in the file. Three compensations: | |
| (a) the Stop hook input carries `last_assistant_message` — when the | |
| parsed transcript lacks it, it is appended to the export; | |
| (b) every hook run also "heals" sibling sessions: any export still | |
| present in the inbox whose source .jsonl is newer gets re-exported | |
| (by then the dead session's file is fully flushed). Exports already | |
| ingested (moved out of the inbox) are never recreated; | |
| (c) wire the same command to both Stop and SessionEnd — at SessionEnd | |
| the transcript is complete. | |
| Configuration — environment variables, no code changes needed: | |
| TRANSCRIPT_INBOX target directory in --hook mode, relative | |
| to the project root (default: topics/_inbox) | |
| TRANSCRIPT_USER_LABEL label for the user's messages (default: User) | |
| TRANSCRIPT_ASSISTANT_LABEL label for assistant replies (default: Claude) | |
| Wiring up as Stop + SessionEnd hooks in .claude/settings.json (example): | |
| {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": | |
| "TRANSCRIPT_USER_LABEL=Alice python3 \\"$CLAUDE_PROJECT_DIR/.claude/scripts/export-transcript.py\\" --hook"}]}], | |
| "SessionEnd": [{"hooks": [{"type": "command", "command": | |
| "TRANSCRIPT_USER_LABEL=Alice python3 \\"$CLAUDE_PROJECT_DIR/.claude/scripts/export-transcript.py\\" --hook"}]}]}} | |
| """ | |
| import sys, os, json, re, argparse | |
| from datetime import datetime | |
| # Configuration (see docstring) — overridable via environment variables. | |
| INBOX_DIR = os.environ.get("TRANSCRIPT_INBOX", "topics/_inbox") | |
| USER_LABEL = os.environ.get("TRANSCRIPT_USER_LABEL", "User") | |
| ASSISTANT_LABEL = os.environ.get("TRANSCRIPT_ASSISTANT_LABEL", "Claude") | |
| # Harness wrapper tags whose content is not the user's words. | |
| _STRIP_BLOCKS = [ | |
| r"<system-reminder>.*?</system-reminder>", | |
| r"<command-message>.*?</command-message>", | |
| r"<local-command-stdout>.*?</local-command-stdout>", | |
| r"<local-command-stderr>.*?</local-command-stderr>", | |
| r"<user-prompt-submit-hook>.*?</user-prompt-submit-hook>", | |
| r"<task-notification>.*?</task-notification>", | |
| r"<ide_opened_file>.*?</ide_opened_file>", | |
| r"<ide_selection>.*?</ide_selection>", | |
| ] | |
| _CAVEAT = re.compile(r"^Caveat:.*?(?=\n\n|\Z)", re.DOTALL) | |
| _CMD_NAME = re.compile(r"<command-name>(.*?)</command-name>", re.DOTALL) | |
| _CMD_ARGS = re.compile(r"<command-args>(.*?)</command-args>", re.DOTALL) | |
| # Marker the harness puts before a batch of local built-in commands | |
| # (/model, /cost ...): those are not the user's words to Claude. | |
| _LOCAL_CAVEAT = "<local-command-caveat>" | |
| # Inbox export file names: YYYY-MM-DD-<first 8 chars of session id>.md | |
| _EXPORT_NAME = re.compile(r"^\d{4}-\d{2}-\d{2}-([0-9a-f]{8})\.md$") | |
| def clean_user_text(text): | |
| if not text: | |
| return "" | |
| for pat in _STRIP_BLOCKS: | |
| text = re.sub(pat, "", text, flags=re.DOTALL) | |
| text = _CAVEAT.sub("", text) | |
| # A slash command the user typed: `/name` on its own line, then its | |
| # arguments (the actual message) — instead of the raw tags. | |
| name = _CMD_NAME.search(text) | |
| if name: | |
| args = _CMD_ARGS.search(text) | |
| head = name.group(1).strip() | |
| body = args.group(1).strip() if args else "" | |
| rendered = f"{head}\n\n{body}" if body else head | |
| text = _CMD_NAME.sub("", text) | |
| text = _CMD_ARGS.sub("", text) | |
| text = (rendered + "\n\n" + text.strip()).strip() | |
| return text.strip() | |
| def extract_text_blocks(content): | |
| """Extract only text blocks from message.content (str | list).""" | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| parts = [] | |
| for b in content: | |
| if isinstance(b, dict) and b.get("type") == "text": | |
| parts.append(b.get("text", "")) | |
| return "\n\n".join(p for p in parts if p) | |
| return "" | |
| def extract_ask_answer(entry): | |
| """The user's decision on a button question (AskUserQuestion), or None. | |
| Such an answer arrives as a tool_result — technically not a message, but | |
| effectively the user's words, so it is kept. Read from the structured | |
| `toolUseResult` field ({"questions": [...], "answers": {question: answer}}) | |
| and not from the tool_result prose: the harness has worded that prose | |
| differently across versions and even within one session, while the field | |
| has been stable. A question the user closed without answering carries a | |
| plain string there instead — no answers, nothing to keep. | |
| """ | |
| r = entry.get("toolUseResult") | |
| if not isinstance(r, dict) or "questions" not in r: | |
| return None | |
| answers = r.get("answers") | |
| if not isinstance(answers, dict) or not answers: | |
| return None | |
| pairs = ", ".join(f'"{q}"="{a}"' for q, a in answers.items()) | |
| return "[answer to question] " + pairs + "." | |
| def fmt_time(ts): | |
| """ISO timestamp (UTC) -> local 'YYYY-MM-DD HH:MM'. Empty on failure.""" | |
| if not ts: | |
| return "" | |
| try: | |
| dt = datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone() | |
| return dt.strftime("%Y-%m-%d %H:%M") | |
| except Exception: | |
| return "" | |
| def parse(path): | |
| meta = {"title": None, "date": None, "session": None, "branch": None} | |
| turns = [] # list of (speaker, text, time) | |
| local_cmd = False # inside a batch of local built-in commands | |
| with open(path, encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| o = json.loads(line) | |
| except json.JSONDecodeError: | |
| continue | |
| if o.get("isSidechain"): | |
| continue | |
| t = o.get("type") | |
| if t == "ai-title" and o.get("aiTitle"): | |
| meta["title"] = o["aiTitle"] | |
| if meta["date"] is None and o.get("timestamp"): | |
| meta["date"] = o["timestamp"][:10] | |
| if o.get("sessionId"): | |
| meta["session"] = o["sessionId"] | |
| if o.get("gitBranch"): | |
| meta["branch"] = o["gitBranch"] | |
| if t not in ("user", "assistant"): | |
| continue | |
| m = o.get("message") | |
| if not isinstance(m, dict): | |
| continue | |
| role = m.get("role") | |
| ts = fmt_time(o.get("timestamp")) | |
| content = m.get("content") | |
| if role == "user": | |
| raw = extract_text_blocks(content) | |
| # System/skill injections (isMeta) are not the user's messages. | |
| if o.get("isMeta") or m.get("isMeta"): | |
| if _LOCAL_CAVEAT in (raw or ""): | |
| local_cmd = True | |
| continue | |
| # The context-compaction summary is stored in the user role | |
| # for the model's own benefit (isCompactSummary) — nobody | |
| # said it, so it must not be attributed to the user. | |
| if o.get("isCompactSummary"): | |
| continue | |
| # A local built-in command (/model ...) and its stdout follow | |
| # the caveat marker; skip them. Anything else ends the batch. | |
| if local_cmd and ( | |
| _CMD_NAME.search(raw or "") or "<local-command-stdout>" in (raw or "") | |
| ): | |
| continue | |
| local_cmd = False | |
| # A button-question answer arrives as a tool_result — | |
| # it is the user's decision. | |
| ans = extract_ask_answer(o) | |
| if ans: | |
| turns.append((USER_LABEL, ans, ts)) | |
| continue | |
| text = clean_user_text(raw) | |
| if text: | |
| turns.append((USER_LABEL, text, ts)) | |
| elif role == "assistant": | |
| # "API Error: ..." entries are synthesized by the harness | |
| # (isApiErrorMessage, model "<synthetic>") — not a reply. | |
| if o.get("isApiErrorMessage"): | |
| continue | |
| local_cmd = False | |
| text = (extract_text_blocks(content) or "").strip() | |
| if text: | |
| turns.append((ASSISTANT_LABEL, text, ts)) | |
| return meta, turns | |
| EMOJI = {USER_LABEL: "🟦", ASSISTANT_LABEL: "🟩"} | |
| def render(meta, turns): | |
| title = meta["title"] or "Conversation" | |
| out = [f"# {title}", ""] | |
| info = [] | |
| if meta["date"]: | |
| info.append(f"Date: {meta['date']}") | |
| if meta["session"]: | |
| info.append(f"session {meta['session']}") | |
| if info: | |
| out += [" · ".join(info), ""] | |
| last = None | |
| for speaker, text, ts in turns: | |
| if speaker != last: # header only when the speaker changes | |
| emoji = EMOJI.get(speaker, "") | |
| name = f"{emoji} {speaker}".strip() | |
| label = f"# {name} • {ts}" if ts else f"# {name}" | |
| out.append(label) | |
| out.append("") | |
| out.append(text) | |
| out.append("") | |
| last = speaker | |
| return "\n".join(out).rstrip() + "\n" | |
| def out_name(meta): | |
| date = meta["date"] or "0000-00-00" | |
| sid = (meta["session"] or "unknown")[:8] | |
| return f"{date}-{sid}.md" | |
| def write_atomic(path, text): | |
| """Write via a hidden temp file + rename, so concurrent hook runs | |
| (two sessions open at once) never leave a half-written export.""" | |
| d, name = os.path.split(path) | |
| tmp = os.path.join(d, f".{name}.tmp") | |
| with open(tmp, "w", encoding="utf-8") as f: | |
| f.write(text) | |
| os.replace(tmp, path) | |
| def last_assistant_text(data): | |
| """Text of the Stop hook's `last_assistant_message`, tolerant to shape | |
| (plain string or a message object/content-block list). '' if absent.""" | |
| v = data.get("last_assistant_message") | |
| if isinstance(v, str): | |
| return v.strip() | |
| if isinstance(v, dict): | |
| return (extract_text_blocks(v.get("content")) or "").strip() | |
| if isinstance(v, list): | |
| return (extract_text_blocks(v) or "").strip() | |
| return "" | |
| def heal_stale_exports(inbox, transcripts_dir, skip_name): | |
| """Re-export inbox files whose source .jsonl grew after the export. | |
| Claude Code flushes transcript tails lazily, so the last assistant | |
| message(s) of a session usually land in the .jsonl only after its | |
| final Stop hook already ran. Any later hook run repairs those files | |
| here. Only files still PRESENT in the inbox are touched — a missing | |
| export means the session was already ingested (or never exported), | |
| and recreating it would resurrect ingested content. | |
| """ | |
| try: | |
| inbox_files = os.listdir(inbox) | |
| except OSError: | |
| return | |
| for name in inbox_files: | |
| if name == skip_name: | |
| continue | |
| m = _EXPORT_NAME.match(name) | |
| if not m: | |
| continue | |
| sid8 = m.group(1) | |
| try: | |
| sources = [ | |
| f for f in os.listdir(transcripts_dir) | |
| if f.startswith(sid8) and f.endswith(".jsonl") | |
| ] | |
| if len(sources) != 1: | |
| continue | |
| src = os.path.join(transcripts_dir, sources[0]) | |
| dst = os.path.join(inbox, name) | |
| if os.path.getmtime(src) <= os.path.getmtime(dst): | |
| continue | |
| meta, turns = parse(src) | |
| if not turns: | |
| continue | |
| write_atomic(dst, render(meta, turns)) | |
| except Exception: | |
| continue # a hook must never fail | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("input", nargs="?") | |
| ap.add_argument("-o", "--output") | |
| ap.add_argument("--hook", action="store_true") | |
| args = ap.parse_args() | |
| if args.hook: | |
| try: | |
| data = json.load(sys.stdin) | |
| except Exception: | |
| return 0 # a hook must never fail | |
| path = data.get("transcript_path") | |
| root = os.environ.get("CLAUDE_PROJECT_DIR") or data.get("cwd") or os.getcwd() | |
| if not path or not os.path.exists(path): | |
| return 0 | |
| inbox = os.path.join(root, INBOX_DIR) | |
| try: | |
| os.makedirs(inbox, exist_ok=True) | |
| meta, turns = parse(path) | |
| # The async-written .jsonl may lack the turn's final reply — | |
| # take it from the hook input instead (Stop provides it). | |
| tail = last_assistant_text(data) | |
| if tail and not any( | |
| sp == ASSISTANT_LABEL and tx == tail for sp, tx, _ in turns | |
| ): | |
| turns.append((ASSISTANT_LABEL, tail, "")) | |
| current = None | |
| if turns: | |
| current = out_name(meta) | |
| write_atomic(os.path.join(inbox, current), render(meta, turns)) | |
| heal_stale_exports(inbox, os.path.dirname(path), current) | |
| except Exception: | |
| return 0 | |
| return 0 | |
| if not args.input: | |
| ap.error("INPUT.jsonl or --hook required") | |
| meta, turns = parse(args.input) | |
| text = render(meta, turns) | |
| if args.output: | |
| with open(args.output, "w", encoding="utf-8") as f: | |
| f.write(text) | |
| print(f"Written: {args.output} ({len(turns)} turns)") | |
| else: | |
| sys.stdout.write(text) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "hooks": { | |
| "Stop": [ | |
| { | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "TRANSCRIPT_INBOX=transcripts/_inbox TRANSCRIPT_USER_LABEL=Alice python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/export-transcript.py\" --hook" | |
| } | |
| ] | |
| } | |
| ], | |
| "SessionEnd": [ | |
| { | |
| "hooks": [ | |
| { | |
| "type": "command", | |
| "command": "TRANSCRIPT_INBOX=transcripts/_inbox TRANSCRIPT_USER_LABEL=Alice python3 \"$CLAUDE_PROJECT_DIR/.claude/scripts/export-transcript.py\" --hook" | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment