AIの文脈忘れを予防する
Claude Codeに指示を出していると、知らず知らずにセッション(チャット)が長くなって、AIが以前の文脈を忘れ始めます。そんな時「そろそろ新しいセッションでやり直したほうがいいよ」と言ってくれる仕組みを作りました。最初はPreCompactをフックしたものの、それだけでは解決しなかったので少し工夫しています。
人間に たとえてみると
たとえば1人の人間に1時間かけて様々な情報を連続で話したとします。
最初は「うんうん」と聞いて理解していたのに、連続で30分も話し続けたら、さすがに疲れて最初の頃に聞いた情報は曖昧になってしまうでしょう。
ある程度の情報が頭の中に入ったら、一度メモに書き出して頭をスッキリさせ、次の情報を理解していくのがベター。
これをClaude向けに実装してみました。次のような動きになります。
- Claudeがコンテキストの増大を検知して、自動的に今までの経緯を外部メモに保存する
- Claudeが人間に「/clear してコンテキストをリセットして」とお願いしてくる
- 人間が /clear すると、先ほどの外部メモを最小限参照しながら新しいセッションが始まる
こんな風に処理してくれます。
この記事が役に立つ場面
- Claude Code で長時間の作業を1セッションで続けていて、途中から回答の質が落ちる感覚がある
- でもコンテキストが途切れるのが怖くて、つい続けてしまう
- 続けた末にClaudeのコンテキスト自動圧縮(auto-compact)で圧縮されて、大切なコンテキストが抜け落ちた
- auto-compact を止めるフックを入れてみたけど、フックが発火しない
- 1M コンテキストのモデル(
claude-opus-5[1m]など)を使っている
仕組みの概要
claude-opus-5[1m] など大きなコンテキストのモデルでは、auto-compact が動く前に文脈が 80万トークン超まで育ちます。コンテキストを自動圧縮する直前に呼ばれる PreCompact フックは「溢れる時」に鳴る設計なので、トークンが巨大になっても限界が来ないと発火しません。でもコンテキストに余裕はあっても情報が巨大になっていることには変わりありません。AIの回答はじょじょに曖昧になっていきます。
そこでトークン閾値ベースのフックを2つ足してみました。セッションをまたいで経緯を残す外部メモ(dejavu:後述)と組み合わせて動かしています。
| 部品 | 種別 | 役割 |
|---|---|---|
| 外部メモ | CLI(ここでは自作の dejavu) |
経緯を保存し、次のセッションで取り出す置き場 |
| context-watch | UserPromptSubmit フック | 文脈量が閾値を超えたら、AI に保存と /clear の提案をさせる |
| resume-inject | SessionStart (matcher: clear) フック |
/clear 直後に前回の経緯を自動で読み込ませる |
フック2つはコンテキスト量の検知とコンテキスト受け渡しを担当していて、コンテキストそのものを持ちません。保存する先を別に用意して /clear 前後の知識をつなげています。
人間の操作は /clear を1回打つだけに抑えました。
PreCompact を入れていたのに効いていなかった
もともとは auto-compact をブロックして「保存してから新セッションで再開して」と促すフックを入れていました。
#!/bin/bash
input=$(cat)
reason=$(printf '%s' "$input" | /usr/bin/python3 -c '
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
d = {}
print(d.get("compact_reason") or d.get("trigger") or "")
' 2>/dev/null)
if [ "$reason" = "auto" ]; then
echo 'コンテキストが溢れそうです。経緯を保存して、新しいチャットで再開してください' >&2
exit 2
fi
exit 0
書き方に問題はなかったのですが、発火する場面がなかなか来なかったのでローカルログ(~/.claude/projects/**/*.jsonl)で上位セッションを調べたところ、こうなっていました。
| セッション | リクエスト数 | 最大文脈 | 平均文脈 | compact 回数 |
|---|---|---|---|---|
| A | 1305 | 834k | 486k | 0 |
| B | 735 | 829k | 450k | 0 |
| C | 433 | 998k | 418k | 0 |
コンテキストが1M の枠に収まっているのでcompact は一度も発生していません。コンテキストが溢れないまま、平均48万トークンの文脈で1300回リクエストが飛んでいます。
コスト面でも影響が出ます。もし API 従量課金で換算すると、この期間の支出の8割が上位3セッションに集中しています。内訳の過半はキャッシュ読込で、文脈を長く保つほどここが支配的になることが分かります。
質の面のほうが問題かもしれません。文脈が育つほど、セッション前半で決めた方針と後半の判断がずれやすくなります。溢れていないので警告は出ません。
解決の仕組み
前述した3つの部品が順に動きます。
[context-watch] 文脈量が閾値を超えた
↓ additionalContext で指示
[AI] 本題を終えたあと、経緯を外部メモに保存
↓ dejavu add --category context
[外部メモ] 経緯が残る(セッションが消えても残る)
↓
[人間] /clear
↓ SessionStart(clear)
[resume-inject] 外部メモから最新の経緯を読んで stdout に出す
↓ dejavu resume
[新セッション] 経緯を持った状態で開始
経緯を保存する側(外部メモ)
まず必要なのがコンテキストの保存先です。保存先がないと context-watch は「保存しろ」と言うだけで終わり、resume-inject は読むものがありません。ここでは自作の情報保存 CLI(dejavu)を使っています。Claude Code から見ると次の2つが揃っていればいいわけです。
| 操作 | コマンド | 使う側 |
|---|---|---|
| 保存 | dejavu add "<1行要約>" --category context --body - |
AI が実行する |
| 取り出し | dejavu resume |
resume-inject が実行する |
保存の中身は AI が書きます。context-watch が注入する指示で、何を書くかを指定します。
- 何が終わったか
- 次に何をするか
- 関わるファイルパス・関数名
このうち3つめが効きます。次のセッションは、経緯の文章より「どこを見ればいいか」を必要とすることが多いからです。パスと関数名があれば、そこから読み直せるので確実ですが、判断の経緯を長々と書いても再開の役には立ちにくいのです。
保存は AI が外部ツールを呼び出して行うので、フック側に保存処理は持たせません。追加のモデル呼び出しも発生しません。
コラム: dejavu について
Claude の記憶を3層に分ける OSS として作ったもの。Claude 自身のメモ帳、プロジェクト単位の日記、Obsidian の本棚、という分け方をしています。SQLite と Markdown で情報を持ち、ネットワーク通信はしません。MCP サーバとして複数の出入口(Chat / Code / Cowork / Xcode など)から同じ知識を参照できます。
この記事で使っているのは「日記」層。プロジェクトごとに経緯が分かれて溜まり、resume で当該プロジェクトの直近の経緯を取り出せます。汎用的な知見のほうは「本棚」の Obsidian に残りますが、今回のセッション再開の話とは別物です。
外部メモに求められる要件
私は外部メモとして自作の dejavu を使いましたが、次を満たせば外部メモは置き換えることができます。ファイルに追記していく形でも成立すると思います。
- CLI から呼べる。フックが叩く側で、AI も叩く側になる
- 「最新の引き継ぎ1件」を決定的に取り出せる。検索で拾う形にすると、取りこぼしたときに気づけないので
- 関係ない話を混ぜないために、プロジェクトごとのスコープに分けることができる
- 出力が数KB程度に収まる。セッション冒頭に注入するので、大きすぎるとコンテキスト圧縮できず本末転倒になる
dejavu resume の出力は現状 9KB 前後で、2〜3kトークンに収まっています。
context-watch(UserPromptSubmit)
プロンプト送信時に transcript の末尾から直近の usage を読み、現在の文脈量を推定します。閾値を超えたら additionalContext で AI に指示を注入します。
- 30万未満:何もしない
- 30万〜50万:区切りが良ければ保存と
/clearを勧めろ、という弱い指示 - 50万以上:本題を終えたあと保存まで済ませて
/clearを促せ、という強い指示
作業の途中で止められると邪魔ですし、無人で回すループ(/loop)も止めたくないので、勝手にブロックはしません。
#!/bin/bash
input=$(cat)
/usr/bin/python3 -c '
import sys, json, os
SOFT = 300_000
HARD = 500_000
TAIL = 1 << 20 # 末尾1MBだけ見る
def emit(msg):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": msg,
}}, ensure_ascii=False))
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(0)
path = d.get("transcript_path") or ""
if not path or not os.path.exists(path):
sys.exit(0)
try:
size = os.path.getsize(path)
with open(path, "rb") as fh:
if size > TAIL:
fh.seek(size - TAIL)
fh.readline() # 途中で切れた行を捨てる
lines = fh.read().decode("utf-8", "replace").splitlines()
except OSError:
sys.exit(0)
ctx = 0
for line in reversed(lines):
if chr(34) + "usage" + chr(34) not in line:
continue
try:
o = json.loads(line)
except Exception:
continue
u = (o.get("message") or {}).get("usage")
if not isinstance(u, dict):
continue
n = ((u.get("input_tokens") or 0)
+ (u.get("cache_read_input_tokens") or 0)
+ (u.get("cache_creation_input_tokens") or 0))
if n:
ctx = n
break
if ctx < SOFT:
sys.exit(0)
k = ctx // 1000
if ctx >= HARD:
emit(
"[context-watch] 現在の文脈量は約 {}k トークン。読込コストが積み上がる域。\n"
"この応答の最後に、ユーザーの指示を待たずに次をやること:\n"
" 1. ここまでの経緯を外部メモに保存する\n"
" (何が終わって、次に何をするか、関わるパス・関数名を密に書く)\n"
" 2. 保存したと1行で報告し、「/clear で続けて」とだけ添える\n"
"本題の作業は普段どおり最後までやること。保存はその後に足す。"
.format(k)
)
else:
emit(
"[context-watch] 現在の文脈量は約 {}k トークン。\n"
"作業の区切りが良ければ、経緯を保存して /clear を勧めること。\n"
"作業の途中なら無視してよい。ユーザーに判断を仰ぐ必要はない。"
.format(k)
)
' <<< "$input" 2>/dev/null
exit 0
transcript は数百MBまで育つので、末尾1MBだけ読みます。実測では 6.4MB のファイルで 0.023 秒でした。毎プロンプト走る部分なので、ここは軽くしておきたいところです。
resume-inject(SessionStart, matcher: clear)
/clear の直後に発火し、前回の経緯を stdout に出します。UserPromptSubmit と SessionStart については、exit 0 の plain-text stdout がコンテキストとして注入されます。
dejavu resume の部分を、自分が使っているメモの読み出しCLIコマンドに置き換えれば動きますので試してみてください。
#!/bin/bash
cat > /dev/null # stdin の JSON は使わないが読み捨てる
command -v dejavu > /dev/null 2>&1 || exit 0
out=$(dejavu resume 2>/dev/null) || exit 0
# 空、または短すぎる場合は注入しない
if [ "${#out}" -lt 80 ]; then
exit 0
fi
echo "前セッションからの引き継ぎ(自動注入)。"
echo "ユーザーはまだ何も指示していない。これを読んだうえで指示を待つこと。"
echo "内容が古い可能性があるので、コードに触る前に現物を確認すること。"
echo
echo "$out"
exit 0
メモのコマンドが無かったり、出力が空のリポジトリの場合には、エラー文がセッション冒頭に混ざって邪魔になるのでガードを入れてあります。
フックの登録
~/.claude/settings.json に追加します。ユーザースコープなので全プロジェクトで使えます。
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash \"$HOME/.claude/hooks/context-watch.sh\"",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"matcher": "clear",
"hooks": [
{
"type": "command",
"command": "bash \"$HOME/.claude/hooks/resume-inject.sh\"",
"timeout": 15
}
]
}
]
}
}
SessionStart の matcher には startup / resume / clear / compact / fork がありますが、ここでは clear に絞っています。
なぜ全自動で /clear できなかったか
そもそも理想のやり方は、閾値を超えたら保存して勝手にセッションが切り替わることでしたが、これは実現できませんでした。
フックは外部コマンドを実行する仕掛けで、セッションそのものを操作する口を持っていません。フックが返せるのは終了コードと標準出力、それに additionalContext のようなJSONフィールドで、いずれも「このターンの中身」に影響する範囲にとどまっています。/clear はセッションの状態を破棄する操作なのでターンの範囲外にあり、フックでは管理できない領域なのです。
フックの側から見ると、こういう分担になります。
| やりたいこと | 可否 | 手段 |
|---|---|---|
| 文脈量を検知する | 可 | transcript_path を読む |
| AI に保存させる | 可 | additionalContext で指示を注入 |
/clear を実行する |
不可 | セッション制御の口がない |
| clear 後に経緯を戻す | 可 | SessionStart の stdout |
/clear の前後はフックで自動化できても、セッションスコープである/clear は残り、結果として人間の操作が1つだけ必要になりました。