Claude Codeが突然記憶喪失になる問題をhookで解決した
Claude Codeで2時間くらい作業してると突然「何の話でしたっけ?」状態になる。hookで自動復元する仕組みを作って改善した話。
「さっき話したよね?」が通じない
Claude Codeで設計の議論をしながらコードを書いてると、ある瞬間から急にClaudeの応答がズレ始める。
「さっき決めたあの方針でやって」
→ 「どの方針のことでしょうか?」
これがコンテキスト圧縮(compact)。
| 何が起きるか | 結果 |
|---|---|
| 会話が長くなる | Claude Codeが自動的に古い会話を要約・圧縮 |
| 要約の過程で | 直前の設計判断や作業文脈がごっそり抜け落ちる |
| 体感 | 2時間の共同作業の相手が突然記憶喪失になる |
毎回「今こういう状況で、さっきこう決めて、次はこれをやろうとしてて...」と説明し直すのは正直きつい。
hookで「圧縮前に保存→圧縮後に復元」する
Claude Codeにはhookという仕組みがある。特定のイベント(圧縮前、セッション開始時など)に自動でスクリプトを実行できる。
これを使って、こういう流れを作った:
やってることはシンプル。圧縮される前に「今何の話をしてたか」を退避して、圧縮後に戻すだけ。
最初の失敗: そのまま保存したらノイズだらけだった
最初はtranscript(会話ログ)をそのまま保存してみた。200行分をtailして保存、復元時に注入。
全然ダメだった。
transcriptのJSONLには、人間には見えないデータが大量に入っている:
- thinking block — Claudeの内部思考。数千トークンある
- signature — 暗号署名データ。意味不明な文字列
- tool results — ファイルの中身やコマンド出力。巨大
注入できるサイズには上限がある(20KB程度)。この枠をノイズで埋めてしまうと、肝心の「何を話してたか」がほとんど入らない。
解決: JSONLをパースして会話だけ抽出する
やることを変えた。transcriptをそのまま保存するのではなく、パースして必要な部分だけ抽出する。
| 抽出する | 捨てる | |
|---|---|---|
| ユーザー | 発言テキスト | — |
| Claude | テキスト応答 | thinking block、signature |
| ツール | 使用したツール名だけ | 実行結果(ファイル内容、コマンド出力) |
これで同じ20KBの枠に数倍の会話内容が入るようになった。
コード
2ファイル + 設定で完結する。外部依存ゼロ。以下の構成でプロジェクトに配置する:
.claude/
├── hooks/
│ ├── save-context.js # 保存側
│ └── restore-context.js # 復元側
└── settings.json # hook設定
保存側: save-context.js(PreCompact hook)
圧縮直前に走る。transcriptをパースして会話を抽出し、.claude/CONTEXT-SNAPSHOT.mdに保存する。
save-context.js(クリックで展開)
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
// JSONLの1行から「誰が何を言ったか」を抽出する
function extractEntry(line) {
try {
const data = JSON.parse(line);
if (!data.message || !data.message.content) return null;
const role = data.type;
const content = data.message.content;
// ユーザーの発言 → テキストだけ取る
if (role === "user") {
if (data.toolUseResult) return null; // ツール結果は捨てる
const texts = [];
if (typeof content === "string") texts.push(content);
else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") texts.push(item);
else if (item.type === "text" && item.text) texts.push(item.text);
}
}
return texts.length ? `[User] ${texts.join("\n")}` : null;
}
// Claudeの応答 → テキスト + ツール名だけ取る(thinking/signatureは無視)
if (role === "assistant") {
const parts = [];
if (!Array.isArray(content)) return null;
for (const item of content) {
if (item.type === "text" && item.text) parts.push(item.text);
else if (item.type === "tool_use" && item.name) {
const brief = item.input?.description || item.input?.command?.slice(0, 80) || "";
parts.push(`[Tool: ${item.name}]${brief ? " " + brief : ""}`);
}
}
return parts.length ? `[Assistant] ${parts.join("\n")}` : null;
}
} catch { return null; }
return null;
}
// メイン: stdinからhookデータを受け取って処理
let input = "";
process.stdin.on("data", (d) => (input += d));
process.stdin.on("end", () => {
try {
const data = JSON.parse(input);
const transcript = data.transcript_path;
const cwd = data.cwd;
if (!transcript || !fs.existsSync(transcript)) process.exit(0);
const snapshot = path.join(cwd, ".claude", "CONTEXT-SNAPSHOT.md");
const raw = fs.readFileSync(transcript, "utf8");
const lines = raw.split("\n").filter((l) => l.trim());
const tail = lines.slice(-200); // 末尾200行を対象
const entries = [];
for (const line of tail) {
const entry = extractEntry(line);
if (entry) entries.push(entry);
}
// 20KB上限で切る(会話の区切りで切断)
let output = entries.join("\n\n");
if (output.length > 20000) {
output = output.slice(-20000);
const idx = output.indexOf("\n\n[");
if (idx > 0) output = output.slice(idx + 2);
}
fs.mkdirSync(path.dirname(snapshot), { recursive: true });
fs.writeFileSync(snapshot, output);
} catch { process.exit(0); } // エラーでもサイレントに終了(本体を壊さない)
});
復元側: restore-context.js(SessionStart hook)
圧縮後のセッション再開時に走る。保存した内容をClaudeのコンテキストに注入して、ファイルを削除する。
restore-context.js(クリックで展開)
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
let input = "";
process.stdin.on("data", (d) => (input += d));
process.stdin.on("end", () => {
try {
const data = JSON.parse(input);
if (data.source !== "compact") process.exit(0); // compact時のみ実行
const snapshot = path.join(data.cwd, ".claude", "CONTEXT-SNAPSHOT.md");
if (!fs.existsSync(snapshot)) process.exit(0);
const content = fs.readFileSync(snapshot, "utf8").slice(0, 20000);
fs.unlinkSync(snapshot); // 読み取ったら削除(1回限り)
// additionalContextとして注入
const output = {
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: [
"## Context from before compaction\n",
"Below is the recent transcript before context was compacted.",
"Use this to maintain continuity of the current task:\n",
content,
].join("\n"),
},
};
console.log(JSON.stringify(output));
} catch { process.exit(0); }
});
settings.json
.claude/settings.json に以下を追加する:
settings.json(クリックで展開)
{
"hooks": {
"PreCompact": [
{
"hooks": [{ "type": "command", "command": "node .claude/hooks/save-context.js" }]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [{ "type": "command", "command": "node .claude/hooks/restore-context.js" }]
}
]
}
}
ハマりポイント
作る過程でいくつかハマった。同じことをやる人向けに残しておく。
実際に使ってみてどうか
この仕組みを入れた状態で日常的にClaude Codeを使っている。正直な感想:
| hookなし | hookあり | |
|---|---|---|
| 圧縮後 | 完全リセット。何も覚えてない | 直前の数十ターンは覚えてる |
| 説明し直し | ゼロから全部 | 細部だけ補足すればOK |
| 体感 | 毎回つらい | ないよりは全然いい |
ただし完璧ではない:
- 20KBに収まる範囲しか復元されない。長い議論の細部は消える
- 復元されるのは「生の会話の断片」であって「要約」ではない。全体像の把握には不完全
- 数時間に渡るセッション(設計議論→実装→記事作成みたいな複数テーマ)だと、前半の話は落ちる
「いきなり記憶喪失」が「ちょっと忘れっぽい」くらいにはなる。でもこのhookだけで全部解決しようとするのは無理がある。実際にはコンテキスト管理を多層で考えるのが現実的:
| 層 | 仕組み | 何を保持するか |
|---|---|---|
| CLAUDE.md | 手動で書く | プロジェクトの方針・ルール |
| PreCompact hook | 自動 | 直前の作業文脈(今回の仕組み) |
| Auto Memory | 自動 | セッション横断の学び |
どれか一つで完璧にはならないが、組み合わせると実用的になる。
まとめ
- Claude Codeの圧縮で文脈が消える問題は、PreCompact + SessionStart hookで自動復元できる
- transcriptをそのまま保存するとノイズだらけ。パースして会話だけ抽出するのがポイント
- Node.jsで書けばWindows/Mac/Linux共通で動く
- 完璧ではないが、hookなしと比べれば作業の連続性は改善する
- 上のコード3つ(save-context.js、restore-context.js、settings.json)をコピペすればそのまま動く
Discussion