transcript.jsonlを直接パースして「何が呼ばれているか」を可視化する
前作「Agent呼び出しをファイルに記録してSlackに流す」でツール呼び出しの記録について書きました。今回はその延長で、記録済みのtranscript.jsonlを直接パースして「どのSkillが何回・どのAgentが何回・どのMCPサーバーが何回呼ばれたか」を集計するスクリプトの話です。
Claude Codeの /usage はコスト($)と残量を出しますが、「今週どのSkillを一番使ったか」「どのAgentが重いか」は見えません。transcript.jsonlには全ての tool_use ブロックが残っているので、それを直接読めば /usage が教えてくれないレイヤーの使用状況を出せます。
困りごと:/usage はコストしか出さない
/usage が出すのは5hブロック・7日ブロックの消費率と、セッション単位のトークン数です。「このAutoTriggerのSkillが実は週20回呼ばれていた」「general-purpose Agentが全Agentの半数を占めている」といった品目別の内訳は出ません。
コスト最適化や環境の健全性チェックをするには、「何にトークンが使われているか」の品目が必要です。transcript.jsonlはその情報源として最適で、~/.claude/projects/ 以下に全セッション分が蓄積されています。
transcript.jsonl の構造
各ファイルは1行1レコードのJSONLです。Claude Codeが送受信した各ターンが1レコードになります。
{
"type": "assistant",
"uuid": "...",
"timestamp": "...",
"sessionId": "...",
"message": {
"model": "claude-sonnet-4-6",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_01M5Rw...",
"name": "Read",
"input": { "file_path": "~/..." },
"caller": { "type": "direct" }
}
],
"usage": { "input_tokens": 12043, "output_tokens": 421 }
}
}
集計に必要なのは message.content[] の type == "tool_use" ブロックだけです。name フィールドがツール名、input に引数が入っています。
| ツール | 集計のキー | 抽出先 |
|---|---|---|
Skill | input.skill | スキル名(plugin:name 形式でnamespace分離も) |
Agent | input.subagent_type | サブエージェント種別 |
mcp__* | name の第2セグメント | MCPサーバー名(mcp__<server>__<tool>) |
スクリプトの核心
~/.claude/scripts/usage-breakdown.sh はBashのヒアドキュメントでPythonを埋め込んでいます。Bashからディレクトリとウィンドウ期間を受け取り、Pythonがファイルをフルスキャンします。
#!/usr/bin/env bash
ARG="${1:-7d}"
TR_DIR="$HOME/.claude/projects/-Users-matsubara"
[ -d "$TR_DIR" ] || { echo "(no transcript dir)"; exit 0; }
python3 - "$TR_DIR" "$ARG" <<'PY'
import sys, json, datetime, glob, collections, os
tr_dir, arg = sys.argv[1], sys.argv[2]
SHORT = arg == "--short"
ウィンドウ期間はファイルの mtime でフィルタします。--short フラグが来たらParse処理は全て同じですが出力だけ1行に絞ります。
パース本体はシンプルです:
for path in glob.glob(f"{tr_dir}/*.jsonl"):
mtime = os.path.getmtime(path)
if mtime < cutoff_ts: continue
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
rec = json.loads(line)
msg = rec.get("message", {}) if isinstance(rec.get("message"), dict) else {}
content = msg.get("content") if isinstance(msg, dict) else None
if not isinstance(content, list): continue
for block in content:
if block.get("type") != "tool_use": continue
name = block.get("name", "")
inp = block.get("input") or {}
tool_calls[name] += 1
if name == "Skill":
skill_name = inp.get("skill", "?")
skill_calls[skill_name] += 1
elif name == "Agent":
agent_calls[inp.get("subagent_type", "?")] += 1
elif name.startswith("mcp__"):
parts = name.split("__")
if len(parts) >= 2:
mcp_calls[parts[1]] += 1
Skillの plugin:name 形式(例: hookify:configure)は : でsplitしてnamespace集計も別途やっています。これでどのpluginパッケージが重いか、個別スキル名とは別軸で見られます。
実際に走らせてみる
直近7日で走らせると、こう出ます。
$ usage-breakdown.sh 7d
=== usage breakdown (last 7d, 51 transcripts) ===
total tool_use: 4230
--- top tools ---
2583 Bash
567 Read
402 Edit
151 Write
103 mcp__claude-in-chrome__computer
73 mcp__claude-in-chrome__javascript_tool
62 TaskUpdate
50 ToolSearch
42 mcp__claude-in-chrome__navigate
36 TaskCreate
--- top skills (2 unique) ---
1 harness-audit
1 superpowers:brainstorming
--- top agents (5 unique) ---
20 general-purpose
7 Explore
4 ?
2 reviewer
1 fork
--- top MCP servers (4 unique) ---
261 claude-in-chrome
3 claude_ai_Google_Calendar
3 computer-use
2 claude_ai_Gmail
--- top plugin namespaces (1 unique) ---
1 superpowers
この週は51セッション・4,230 tool_useで、Bashが全体の61%(2,583回)を占めていました。MCPは claude-in-chrome が群を抜いていて261回。Agentは general-purpose が最多で20回、Exploreが7回でした。
Skill呼び出しが合計2回しかないのは、この週はSkillをほとんど手動で呼ばずにAutoTrigger(CLAUDE.mdのキーワード一致)で動く設定が多かったためです。AutoTriggerはトランスクリプトには Skill tool_useとして現れるはずなので、少なさは正直に出ています。
subagent_typeが"?"になっているAgentが4回あります。これはAgentツールにsubagent_typeを指定しなかった(デフォルトのgeneral-purpose相当)呼び出しで、スクリプトがinp.get("subagent_type", "?")でフォールバックしている分です。実運用では"?"が増えてきたら「どのSessionか」を絞り込むと、サブエージェント指定漏れの発見に使えます。
--short モードでステータスラインに埋める
--short で1行サマリが出ます。
$ usage-breakdown.sh --short
4235 tool_use across 51 sessions (7d)
Claude Codeのステータスラインのカスタムスクリプトに組み込んでいます。コスト枠(前作の token-budget-advisor.sh 出力)と並べると「使用量と残量が一目で見える」状態になります。
# statusline hook の一例
BUDGET=$(~/.claude/scripts/token-budget-advisor.sh --short)
USAGE=$(~/.claude/scripts/usage-breakdown.sh --short)
echo "💰 $BUDGET | 🔧 $USAGE"
踏んだ落とし穴
mtimeフィルタはファイル単位なので精度が荒い — セッションが日をまたぐと、古いターンも「最近のファイル」として集計に入ります。厳密にやるならrec.get("timestamp")で行レベルフィルタが必要ですが、傾向把握レベルならmtimeで十分でした。- サブディレクトリのJSONLを拾わない —
glob.glob(f"{tr_dir}/*.jsonl")はフラットな1階層しか見ません。サブエージェントのtranscriptは<session-uuid>/subagents/agent-*.jsonlに入るため、現行実装では集計外です。**/*.jsonlに変えると拾えますが、重いので意図的に外しています。 mcp__claude-in-chrome__computerのような長いMCPツール名 —split("__")の第2セグメントを取る実装なので、parts[1]だけが残りサーバー名は正しく抽出できます。ただしサーバー名自体に__が入るケースでは誤抽出します(今のところ遭遇なし)。- 30d ウィンドウは数分かかる — 890ファイル超をフルスキャンするため、
7dまたは--shortを日常使いにして、深掘り時だけ30dにするのが現実的です。
まとめ
transcript.jsonlのmessage.content[].type == "tool_use"だけを拾えば、Skill・Agent・MCPサーバーの呼び出し回数が取れる- Pythonのカウンタで
most_common(10)を出すだけで「重い品目」がすぐ分かる mtime期間フィルタとPythonのヒアドキュメント埋め込みでBashスクリプト1本に収まる--shortで1行サマリにしてステータスラインに常時表示すると、環境の健全性をコストとは別軸で監視できる
/usage でコストを見て、このスクリプトで品目の内訳を見る。2つを合わせると「何のために何トークン使ったか」の解像度が上がります。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!