概要
Claude Code はセッションの全履歴を JSONL で書き出しており、直接読めば /cost や /context では見えないターンごとのトークン推移、ツール呼び出しの傾向、コンパクションの位置まで取れる。ただし解説記事のスキーマ記述は環境によって成り立たず、しかも外れ方の多くがエラーではなく出力 0 件の成功になる。
41 セッション・29,043 行(Claude Code 2.1.222〜2.1.258)を横断集計した実測から、パーサを書く前に確かめることをまとめる。数値は絶対値ではなく確認の手順として読む。
詳細
保存先
~/.claude/projects/<encoded-project-path>/<session-uuid>.jsonl
プロジェクトパスはディレクトリ名にエンコードされ(/Users/you/code/my-app → -Users-you-code-my-app)、1 セッション 1 ファイル・追記専用。
~/.claude/sessions/<id>/transcript.jsonl とする解説もあるが、手元には 1 件も無かった。厄介なのは ~/.claude/sessions/ 自体は実在すること。中身はポート番号を冠した .json と権限 600 の .key で、会話ログではない(.key は開いたり貼ったりしない)。存在しないディレクトリなら即座に気づけるが、実在するのに目的のファイルが無い場所には着地してしまう。
JSONL が規格化しているのは封筒だけ
JSON Lines は標準化団体の規格ではなく、要件は UTF-8(BOM なし)・各行が valid な JSON・行終端 \n の 3 つだけ。MIME も未標準化と仕様自身が書いている。
| 位置づけ | MIME | 行区切り | |
|---|---|---|---|
| JSON Lines | jsonlines.org のコミュニティ仕様 | application/jsonl(未登録) | \n |
| NDJSON | ndjson-spec v1.0.0 | application/x-ndjson(未登録) | \n。空行は無視してよい(MAY) |
| JSON text sequences | RFC 7464(正式 RFC) | application/json-seq(IANA 登録済み) | 各レコードの前に RS 0x1E。JSONL とは非互換 |
どの仕様も行の中身は決めていない。Claude Code の行スキーマはアプリケーション定義で、参照すべき共通の権威がそもそも無いので、解説どうしが食い違う。
実測したスキーマ
- 全行にあるトップレベルキーは
typeだけ。sessionId98%、timestamp79%、uuid/parentUuid/cwd/gitBranch/version74%、message55%。o['uuid']と書けば 26% の行でKeyError、parentUuidで張った会話ツリーはログの 74% 分しか無い sessionIdとsession_idが同じログに共存する。片方だけ見ると取りこぼすtypeは 17 種類で、会話本体(assistant34%・user21%)以外の 45% はメタイベント(attachment17%、last-prompt、mode、permission-mode、pr-link、ai-title、cost-stateなど)。知らない type はraiseせず捨てる実装にする。pr-linkには PR 番号と URL、cost-stateには累計コストが入っているtype: "user"の 92.5% はツール実行結果。ツールの結果はモデルへ返すためにrole: "user"で記録されるので、人間の発言は全 29,043 行のうち 465 行(1.6%)。contentは文字列の場合と配列の場合の両方がある- 人間の発言を拾うなら
type: "last-prompt"が素直。promptIdはほぼ全user行にあるので、絞り込みではなく「1 回の指示でツール往復が何回あったか」のグルーピングに使う
def is_human_utterance(o):
if o.get('type') != 'user':
return False
msg = o.get('message')
if not isinstance(msg, dict):
return False
c = msg.get('content')
if isinstance(c, str):
return bool(c.strip())
if isinstance(c, list):
return any(i.get('type') == 'text' for i in c if isinstance(i, dict))
return False
0 件成功を避ける
- 他 CLI 向けのパーサを流用しない — Codex CLI 形式(
event_msg/response_item+payload.*)のパーサを当てると、payloadキーが 1 行も無いので.get("payload", {})で全行が静かに捨てられ、正常終了して出力は空になる glob.glob('~/...')はチルダを展開しない —os.path.expanduserを通さないとエラーも出さずに 0 ファイル- 件数をアサーションに入れる —
assert filesとassert matched > 0, f'0 件(総行数 {total})。スキーマ想定を疑うこと' - 自分を含む母集団は 1 パスで全指標を出す — 集計中のセッションもログを書き続けるので、別タイミングの数値を混ぜると内訳の合計が合わない
最初に走らせるべきは整形スクリプトではなく、type の分布とキー出現率を出す 20 行ほどの集計である。
ツール呼び出しとトークン
- ツール呼び出しは
assistant行のcontent配列内のtool_useブロック。MCP ツールはmcp__<server>__<tool>なのでstartswith('mcp__')で選別できる(サーバー名にハイフンが入りうるので最初と最後の__で分割) - 同じ ID のブロックが複数行に記録されることがあるので、回数はブロック数ではなくユニークな
tool_use.idで数える。tool_result.tool_use_idと突き合わせると実行中の呼び出しが分かる - ターンの実プロンプトサイズは
input_tokens + cache_read_input_tokens + cache_creation_input_tokens。キャッシュヒット率は 98.32% だった - 「初回ターンの
cache_readは全セッションで一致する固定コスト」という主張は再現せず、40 セッションで 14 通り(0〜30,799)に散った。手法は再現するが、そこで得た定数は自分の環境の定数にすぎない - ピーク
ctxはclaude-opus-5で 765K に達した(1M 版を使用)。model文字列だけではウィンドウ長は決まらないので、コンパクション閾値を語るときは実際のウィンドウ長とセットで書く modelが<synthetic>の行は API 呼び出しを伴わない内部生成で、コスト集計から外す
関連ページ
- Claude Code — ログを書き出す本体
- 沈黙する失敗 — 0 件で成功するパーサ
- コンテキスト圧縮 — ログから発動位置を追える圧縮カスケード
- Context Rot — コンテキストの劣化と管理