🤖

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