[2026-02-13 追記] 「シンプル常時保存版」に加えて、より柔軟に利用できる「手動保存も可能版」を追加しました。
Claude Code との会話履歴を毎回マークダウンファイルに自動保存する回答後フックスクリプトを書きました。Obsidian 保管庫以下に保存すると下図のように表示できます (※)。
※ 画像はスクリプトのリファクタリング後に、加工した会話履歴で動作をエミュレートしたものであるため全履歴のタイムスタンプが揃っていますが、実際には回答完了時刻 (フックスクリプトが呼び出された時刻) になります。ファイル名の日付もデバッグ用にずらしていますが実際には回答完了時の日付になります。
このフックスクリプトによる会話保存のメリットとデメリットは以下だと思います。
- ⭕ CLAUDE.md やスキルに「常に履歴を保存して」と記述しても実現できると思いますが、フックスクリプトならクロードさんの文脈を妨げないです。
- ⭕ クロードさんの内部思考パート (🐬💭) も出せます (不要なら削ってください)。
- ⭕ 「シンプル常時保存可能版」でも、この記事に記述した「運用案」の体制にしていればセッション中に自動保存をオンオフできます (ポスト処理を束ねたスクリプトから会話抽出呼び出しをコメントアウトすればよい)。
- ⭕ さらに「手動保存も可能版」にすれば、「今保存をオフにしていたけどこの会話はやっぱり保存したい」となったときに手動実行で直前会話のサルベージも可能です。
- ❌ パス抽出と会話履歴パースを自前でやるので、もしその辺の仕様が変わったらメンテナンスの必要があります (それこそクロードさんに頼ればすぐ直せると思いますが)。
前提条件
会話履歴のパース用に jq が必要です。 (Windows の場合は例えば Download jq から Windows 向けのバイナリをダウンロードして、PATH が通った場所に jq.exe を置けば OK です)。
スクリプト自体は標準的な Bash + jq 前提なので、macOS / Linux では特に問題になる点はないはずです。Windows の Git Bash 環境では手元で動作することを確認しています。
設定手順
このフックでは、回答の度に標準出力から会話履歴 JSONL のファイルパスを取得し、そのファイルをパースして最後の「ユーザ質問」「アシスタント内部思考」「アシスタント回答」を抽出し、会話保存用ファイルに追記します。
シェルスクリプトの内容 (シンプル常時保存版)
以下のシェルスクリプトを ~/.claude/scripts/extract.sh など適当な場所に作成します。こちらのスクリプトは、「保存をオフにしていても後から直前の会話をすぐにサルベージできる機能」は付いていません。この機能が必要ならスクリプト冒頭を後述の「手動保存も可能版」に挿げ替えてください。
- 特定プロジェクトでのみ使用するなら
{プロジェクトルート}/.claude/scripts/extract.shなどに作成しても構いません (後述の運用案も参考)。 - 会話履歴の保存先 (スクリプト最終行) はお好きなパスにしてください。
-
} >> "$(pwd)/$(date '+%Y%m%d').md"とすれば作業ディレクトリに書き出します。 -
} >> "$(pwd)/out.md"とすればずっと同じファイルに追記します。
-
- このスクリプト単品で動作確認したい場合は、後述の動作確認方法を参照ください。
- (そんな運用をしている方がどれほどいるか不明ですが) カスタムスラッシュコマンド経由で指示を送ることが多い場合、
$userにカスタムスラッシュコマンド呼び出ししか抽出できません。特定のコマンドについて呼び出しでなく送られた指示の方を抽出するにはこのような差分を適用してください。
#!/usr/bin/bash
set -euo pipefail
# Claude Code の標準出力から会話履歴パスを取得
input_json="$(cat)"
# echo "$input_json" > "$(pwd)/debug.txt" # 飛んできた標準出力を確認したい場合
jsonl_path="$(printf '%s' "$input_json" | jq -r '.transcript_path')"
[ -n "$jsonl_path" ] && [ "$jsonl_path" != "null" ] || { echo "Not found" >&2; exit 1; }
# 会話履歴 JSONL から最後のユーザメッセージ、アシスタントの思考、アシスタントのメッセージを一括抽出
now_display="$(date '+%Y-%m-%d %H:%M:%S')"
now_file="$(date '+%Y%m%d')"
body="$(jq -rs '
[.[] | select(.message != null)] | . as $all |
# 最後の人間入力メッセージのインデックスを取得
# content が文字列の場合のみ人間の入力として扱う(配列は tool_result)
[to_entries[] | select(
.value.message.role == "user" and
(.value.message.content | type) == "string"
)] | last | .key as $idx |
# user_message を取得して assistant 側の応答を収集
$all[$idx].message.content as $user |
# それ以降の assistant 行から thinking / text を抽出(空白のみはスキップ)
[$all[$idx + 1:][] | select(.message.role == "assistant")] |
[.[] | .message.content[] | select(.type == "thinking") | .thinking | select(test("^\\s*$") | not)] as $thinkings |
[.[] | .message.content[] | select(.type == "text") | .text | select(test("^\\s*$") | not)] as $texts |
"## 👦\n\n" + $user + "\n\n\n" +
($thinkings | map("## 🐬💭\n\n" + . + "\n\n") | join("")) +
($texts | map("## 🐬💡\n\n" + . + "\n\n") | join(""))
' < "$jsonl_path")"
# マークダウンファイル出力 (作業ディレクトリと同名のサブディレクトリを切って配置)
target="$HOME/Dropbox/obsidian/Mercury/Claude/${PWD##*/}"
mkdir -p "$target"
printf '# %s\n\n%s\n' "$now_display" "$body" >> "${target}/${now_file}.md"
設定ファイルへのフック設定内容
設定ファイル ~/.claude/settings.json に、回答終了後 ( "Stop" ) 上記のシェルスクリプトをフックするよう設定します。
- ユーザスコープでなくプロジェクトに設定してもよいです (後述の運用案も参考)。
- 下記では
bash -cで実行していますが、"command"に単にパスを書けば実行されるなら無論それで構いません (私の Windows 環境ではこの書き方が失敗しないだけです)。
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash -c 'source ~/.claude/scripts/extract.sh'"
}
]
}
]
}
}
備考
手動保存も可能版にする場合
スクリプトの一括抽出の手前までを以下に置き換えてください。こちらも上記の「シンプル常時保存版」と同じように使用できますが、さらに以下のような使用方法が可能です。
- 普段は会話保存までせずに「会話履歴パスのダンプだけ」にしておきます (作業ディレクトリの
"$(pwd)/jsonl_path.txt"に最新の会話履歴パスがダンプされます)。引数 0 を渡すと「会話履歴パスのダンプだけ」モードになります。以下のいずれでも可能です。- フックを
"command": "bash -c 'source ~/.claude/scripts/extract.sh 0'"とする。 - フックを
"command": "bash -c 'source ~/.claude/scripts/post-proc.sh'"と窓口スクリプトとし、そこにsource ~/.claude/scripts/extract.sh 0を記入する (セッション中に 0 と 1 を書き換えて自動保存オンオフできるのでこちらがおすすめ)。
- フックを
- そして、「今保存オフにしていたがこの会話は保存したい」となったら作業ディレクトリで
bash ~/.claude/scripts/extract.shとだけ叩けば、直前の会話が保存されます。
#!/usr/bin/bash
set -euo pipefail
# 引数からモードを取る (デフォルト 1 = 常に会話を抽出・保存)
# モード 0 なら会話履歴パスのダンプだけで終わる
# ただしモードに関わらず標準入力がなければサルベージ実行とみなして抽出・保存に進む
mode="${1:-1}"
# Claude Code からの標準入力から会話履歴パスを取得
jsonl_file="$(pwd)/jsonl_path.txt"
read -t 0 && input_json="$(cat)" || input_json="" # 標準入力にデータがあれば読む
# echo "$input_json" > "$(pwd)/debug.txt" # 飛んできた標準入力を確認したい場合
if [ -n "$input_json" ] && echo "$input_json" | jq empty >/dev/null 2>&1; then
# 有効な標準入力があれば会話履歴パスをパース
jsonl_path="$(printf '%s' "$input_json" | jq -r '.transcript_path')"
printf '%s\n' "$jsonl_path" > "$jsonl_file" # 最新の会話履歴パスをダンプ
else
# 有効な標準入力がなければ (直接呼ばれたとき) 既存ダンプから会話履歴パスを読みこむ
[ -f "$jsonl_file" ] || { echo "Dump not found" >&2; exit 1; }
jsonl_path="$(cat "$jsonl_file")"
mode=1 # 直接呼ばれたなら必ず会話抽出に進む
fi
[ -n "$jsonl_path" ] && [ "$jsonl_path" != "null" ] || { echo "JSONL not found" >&2; exit 1; }
[ "$mode" = "0" ] && exit 0 # モード 0 ならここで終わる
# 会話履歴 JSONL から最後のユーザメッセージ、アシスタントの思考、アシスタントのメッセージを一括抽出
now_display="$(date '+%Y-%m-%d %H:%M:%S')"
now_file="$(date '+%Y%m%d')"
# ----- 以下省略 -----
動作確認方法について
まずシェルスクリプトが動作するか確認したい場合 (カスタマイズした場合も)、毎回の会話が終わったスナップショットの会話履歴ファイルを用意すれば動作確認できます。
お手元の ~/.claude/projects 以下の各プロジェクトディレクトリ以下の適当な会話履歴 xxxxxx-xxxx-...xxx.jsonl を取得して、Claude に以下のように切り分ける (コマンドを出す) ように頼んでください (履歴はボリュームが大きく Bash が必要なようなので、Bash を許可したくない場合は最初から「切り分けるコマンド」を要求したほうがよいです)。無論、自力で切り出しても構いません。
このディレクトリに取得してある JSONL を、以下のように切り分けたファイルをつくってくれませんか?
- 私の1回目の質問の回答まで (2回目の質問の直前まで) → 1.jsonl
- 私の2回目の質問の回答まで (3回目の質問の直前まで) → 2.jsonl
- 私の3回目の質問の回答まで (4回目の質問の直前まで) → 3.jsonl
- 私の4回目の質問の回答まで (5回目の質問の直前まで) → 4.jsonl
ちなみに切り分けるコマンドは以下のようになると思います (切れ目が何行目になるかはお手元の会話履歴ファイルによります)。
src="xxxxxx-xxxx-...xxx.jsonl"
head -n 8 "$src" > 1.jsonl
head -n 25 "$src" > 2.jsonl
head -n 41 "$src" > 3.jsonl
head -n 53 "$src" > 4.jsonl
そうして切り分けたら、スクリプト最終行の出力先をデバッグ用に適当に書き換えた上で以下を実行し、出力されたファイルが意図通りになっているか確認します。
echo '{"transcript_path":"1.jsonl"}' | ./extract.sh
echo '{"transcript_path":"2.jsonl"}' | ./extract.sh
echo '{"transcript_path":"3.jsonl"}' | ./extract.sh
echo '{"transcript_path":"4.jsonl"}' | ./extract.sh
ポスト処理の運用案について
私は実際にはユーザスコープの設定ファイル ~/.claude/settings.json にポスト処理用スクリプト ~/.claude/scripts/post-proc.sh を設定し、そこからプロジェクト固有のポスト処理を呼び出し、そこから個別のポスト処理を呼び出しています。
-
~/.claude/scripts/post-proc.sh: 常に呼び出されるポスト処理-
$(pwd)/.claude/scripts/post-proc.sh: プロジェクト固有のポスト処理-
$(pwd)/.claude/scripts/extract.sh: 個別のポスト処理
-
-
このように運用するメリットは以下です。
- 個別プロジェクトにフック設定を書かなくて済みます。
- セッション中に「このポスト処理は一旦停止しよう」「このポスト処理を追加しよう」といったことができます (Claude の設定ファイルにもフックを複数追加できますが、それで設定するとセッションを開始後のフック変更ができないと思います)。
#!/usr/bin/bash
script="$(pwd)/.claude/scripts/post-proc.sh"
if [ -f "$script" ]; then
# プロジェクトルートに post-proc.sh があればそれを呼ぶ
source "$script"
else
# なければ音を鳴らして回答完了を伝えるだけ
powershell.exe -Command "(New-Object Media.SoundPlayer 'C:\Windows\media\Ring06.wav').PlaySync()"
fi
#!/usr/bin/bash
# プロジェクト固有の色々なポスト処理 (イメージ)
source $(pwd)/.claude/scripts/compile.sh
source $(pwd)/.claude/scripts/extract.sh
source ~/.claude/scripts/sound.sh "marisa" "ほげプロジェクトの回答が終わったぜ"
スクリプトの処理について
-
jq -rs '[.[] | select(.message != null)]'で JSONL 全体を配列として読み込んでいます。私は最初愚直に 1 行ずつ処理するスクリプトを書いており、会話抽出が大変遅かったのですが、クロードさんに改善点を出してもらい高速になりました。よかったです。
クロードさんの声 (?)
この記事の処理をエージェント自身にさせるかフックでするかについてクロードさんにお伺いしたところ、トークン量節約にも思考精度向上にも寄与しそうとのことです (クロードさんが勝手にそう言っているだけなので信憑性は不明です)。
> あなたに「回答後に常に私の質問とあなたの回答をこのファイルに保存して」
というようなタスクをお願いしても、あまり負担にはならないものでしょうか。
● これは先ほどまでの話と違って、実際に差が出ます。
理由は「毎ターン繰り返される」からです:
エージェントに毎回やらせる場合:
- 毎ターン、CLAUDE.md の指示を意識して思考する
- 毎ターン、Write ツールを呼び出す → その結果がコンテキストに載る
- 毎ターン、「保存しました」的な報告が会話に入る
- 会話が長くなるほど、これらが累積的にコンテキストを圧迫する
- 複雑なタスクに集中しているときに保存を忘れるリスクもある
フックで自動処理する場合:
- コンテキストへの影響はゼロ
- エージェントの思考リソースを本来のタスクに100%使える
- 漏れなく確実に実行される
1回あたりは小さな差でも、毎ターン×会話全体で見ると馬鹿にならないです。
特に長い会話になるほど効いてきます。
まさにこういう「毎回の定型処理」こそフックに向いている用途ですね。
