# Claude Code：コンテキスト圧縮の前後で文脈を保存・復元する仕組み（導入手順書）

> このドキュメントはAIアシスタント（Claude Code）が読んで、そのまま導入作業を行うことを想定した手順書です。
> 人間のユーザーは、自分のClaude Codeに「このURLを読んで、この仕組みを導入して」と伝えるだけで構いません。

---

## AIへの指示（最初に読むこと）

あなたがこの手順書を読んでいるAIアシスタントなら、以下の原則を守って導入してください。

1. **既存設定を壊さない。** `~/.claude/settings.json` に既存のフックがある場合は、必ずマージする（上書きしない）。編集前にバックアップを取る
2. **保存先はユーザーに確認する。** 会話ログの全文が平文で保存される。Dropbox・iCloud等のクラウド同期フォルダを保存先にする場合は、そのリスクを一言伝えてから決める
3. **導入後にテストを実行する**（手順6）。動作確認まで終えてから完了報告する
4. ユーザーの環境がこの手順書の前提と異なる場合（Pythonが無い等）は、無理に進めず状況を報告する

---

## 1. これは何か

Claude Codeは会話が長くなると、過去のやり取りを自動で要約に置き換える（コンテキスト圧縮）。要約は全体像を保つが、直近の細かい決定・言い回しは失われ、「さっき決めたことを覚えていない」という現象が起きる。

この仕組みは2つのフックでそれを補う。

| フック | タイミング | 役割 |
|---|---|---|
| `PreCompact` | 圧縮の**直前** | その時点の会話全文をMarkdownでディスクに保存する |
| `SessionStart`（matcher: `compact`） | 圧縮の**直後** | 保存したばかりのファイルの**末尾4,000字**を新しいコンテキストに注入する |

結果として、圧縮直後のAIの手元には「要約」と「直前のやり取りの原文」の両方が置かれる。手動の `/compact` でも自動圧縮でも同じように動く。

### 設計上の判断（変更する場合は理由を理解してから）

- **注入は末尾4,000字だけ**：全部戻すと再圧縮の循環になる。橋を架ける最小量だけ戻す
- **直近30分のスナップショットだけが注入対象**：古いログの誤注入を防ぐ。何も注入しないほうが誤った文脈より安全
- **同一セッションIDのファイルだけを探す**：複数セッション並行でも混線しない
- **フックは失敗しても黙って終了する**：ログが読めなくても圧縮をブロックしない

## 2. 前提

- Claude Code（フック機能が使えるバージョン）
- Python 3（macOS / Linux標準の `python3` で動く。外部ライブラリ不要）

## 3. 保存先ディレクトリを決める

ユーザーに保存先を確認し、環境変数ではなくスクリプト内の `NOTES_DIR` を書き換えるか、デフォルト（`~/claude-session-notes`）のまま使う。

⚠️ **注意して伝えること：** 会話の全文が平文で保存される。APIキーや個人情報を会話に貼れば、それもファイルに残る。クラウド同期フォルダ配下に置く場合はこの点を必ずユーザーに伝える。

```bash
mkdir -p ~/claude-session-notes
```

## 4. スクリプトを2本設置する

### 4-1. 保存側：`~/.claude/precompact_snapshot.py`

```python
#!/usr/bin/env python3
# PreCompactフック: 会話圧縮の直前に、その時点の会話スナップショットをMarkdownで保存する。
# stdinからフック入力JSON(session_id, transcript_path, trigger, cwd等)を受け取る。
import sys, json, os, datetime

NOTES_DIR = os.environ.get(
    "SESSION_NOTES_DIR",
    os.path.expanduser("~/claude-session-notes"),
)


def text_from_content(content):
    """messageのcontentからテキスト/ツール概要を抽出して返す"""
    if isinstance(content, str):
        return content.strip()
    parts = []
    if isinstance(content, list):
        for b in content:
            if not isinstance(b, dict):
                continue
            t = b.get("type")
            if t == "text":
                parts.append(b.get("text", "").strip())
            elif t == "tool_use":
                name = b.get("name", "tool")
                parts.append(f"[tool: {name}]")
            elif t == "tool_result":
                parts.append("[tool_result]")
    return "\n".join(p for p in parts if p).strip()


def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        data = {}

    transcript = data.get("transcript_path", "")
    sid = data.get("session_id", "") or "unknown"
    trigger = data.get("trigger", "auto")  # "manual" or "auto"
    cwd = data.get("cwd", "")

    if not transcript or not os.path.isfile(transcript):
        # トランスクリプトが取れない場合も無害に終了（圧縮はブロックしない）
        sys.exit(0)

    rows = []
    try:
        with open(transcript, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    obj = json.loads(line)
                except Exception:
                    continue
                rows.append(obj)
    except Exception:
        sys.exit(0)

    lines = []
    for obj in rows:
        typ = obj.get("type")
        if typ not in ("user", "assistant"):
            continue
        msg = obj.get("message", {})
        if not isinstance(msg, dict):
            continue
        body = text_from_content(msg.get("content", ""))
        if not body:
            continue
        role = "👤 ユーザー" if typ == "user" else "🤖 Claude"
        lines.append(f"### {role}\n\n{body}\n")

    ts = datetime.datetime.now().strftime("%Y-%m-%d_%H%M%S")
    os.makedirs(NOTES_DIR, exist_ok=True)
    fname = f"{ts}_{trigger}_{sid[:8]}.md"
    path = os.path.join(NOTES_DIR, fname)

    header = (
        f"# 会話スナップショット（圧縮直前）\n\n"
        f"- 保存日時: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
        f"- トリガー: {trigger}\n"
        f"- セッションID: {sid}\n"
        f"- 作業ディレクトリ: {cwd}\n"
        f"- 元トランスクリプト: {transcript}\n\n"
        f"---\n\n"
    )

    with open(path, "w", encoding="utf-8") as f:
        f.write(header + "\n".join(lines) + "\n")

    # 索引(INDEX.md)に1行追記
    idx = os.path.join(NOTES_DIR, "INDEX.md")
    first_user = next((r for r in rows if r.get("type") == "user"), None)
    snippet = ""
    if first_user:
        snippet = text_from_content(first_user.get("message", {}).get("content", ""))
        snippet = snippet.replace("\n", " ")[:60]
    with open(idx, "a", encoding="utf-8") as f:
        f.write(f"- [{ts}]({fname}) — {trigger} / {snippet}\n")

    print(json.dumps({"systemMessage": f"会話スナップショットを保存しました: {fname}"}))
    sys.exit(0)


if __name__ == "__main__":
    main()
```

### 4-2. 復元側：`~/.claude/postcompact_context.py`

```python
#!/usr/bin/env python3
# SessionStart(compact)フック: 圧縮直後に、直前のスナップショット末尾を新コンテキストへ注入する。
# PreCompactフック(precompact_snapshot.py)が保存したファイルを読み戻す「復元側」。
import sys, json, os, glob, time

NOTES_DIR = os.environ.get(
    "SESSION_NOTES_DIR",
    os.path.expanduser("~/claude-session-notes"),
)
MAX_AGE = int(os.environ.get("PCC_MAX_AGE", "1800"))   # 直近30分のスナップショットのみ対象
MAX_CHARS = 4000                                        # 注入量の上限（コンテキストを圧迫しない）

def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        data = {}
    sid = (data.get("session_id") or "")[:8]
    if not sid:
        return
    files = sorted(glob.glob(os.path.join(NOTES_DIR, f"*_{sid}.md")), key=os.path.getmtime)
    if not files:
        return
    latest = files[-1]
    if time.time() - os.path.getmtime(latest) > MAX_AGE:
        return  # 古いスナップショットは注入しない（誤文脈の混入防止）
    try:
        txt = open(latest, encoding="utf-8").read()
    except Exception:
        return
    tail = txt[-MAX_CHARS:]
    print("【圧縮前の会話ログ・自動復元（末尾抜粋）】")
    print(f"このセッションは直前に圧縮されました。以下は圧縮前の生ログの末尾です。")
    print(f"要約と食い違う場合はこちらの生ログを優先し、さらに前の文脈が必要なら全文を読むこと: {latest}")
    print("---")
    print(tail)

if __name__ == "__main__":
    main()
```

※ 保存先を変えたい場合は、両スクリプト共通の環境変数 `SESSION_NOTES_DIR` を設定するか、`NOTES_DIR` のデフォルト値を直接書き換える（**2本とも同じ場所を指すこと**）。

## 5. フックを登録する

`~/.claude/settings.json` の `hooks` に以下の2エントリを**マージ**する（ユーザーグローバル設定なので全プロジェクトに適用される）。

⚠️ 既存の `PreCompact` / `SessionStart` エントリがある場合は、配列に要素を**追加**する。丸ごと置き換えない。編集前に `settings.json` のバックアップを取ること。

```json
{
  "hooks": {
    "PreCompact": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/precompact_snapshot.py"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/postcompact_context.py"
          }
        ]
      }
    ]
  }
}
```

`matcher: "compact"` が重要。これが無いと通常のセッション開始のたびに発火してしまう。圧縮を挟んだ再開のときだけ読み戻しが走るようにする条件。

## 6. CLAUDE.mdにルールを追記する

自動注入は末尾4,000字だけなので、それより前の文脈が必要なときにAIが自分で全文を読みに行けるよう、グローバルまたはプロジェクトの `CLAUDE.md` に以下を追記する（保存先パスは手順3で決めたものに合わせる）。

```markdown
# コンテキスト圧縮後の文脈復元

会話が圧縮された直後（要約から再開したとき）や、直前のやり取りの記憶が曖昧だと感じたときは、
`~/claude-session-notes/` にある該当セッションの最新スナップショット（ファイル名末尾がセッションID先頭8文字）をReadして文脈を確認してから応答する。
- 圧縮直後はSessionStart(compact)フックが末尾4,000字を自動注入する。それより前の文脈が必要なときに全文を読む
- 要約とスナップショットの内容が食い違う場合は、スナップショット（生ログ）を優先する
```

## 7. 動作テスト

導入したAI自身が以下を実行して確認する。

```bash
# (1) 保存側：ダミー入力で正常終了するか（トランスクリプトが無ければ無害に終了する）
echo '{}' | python3 ~/.claude/precompact_snapshot.py; echo "exit=$?"

# (2) 復元側：ダミー入力で正常終了するか
echo '{"session_id":"testtest-0000"}' | python3 ~/.claude/postcompact_context.py; echo "exit=$?"

# (3) settings.jsonが壊れていないか
python3 -c "import json; json.load(open('$HOME/.claude/settings.json')); print('settings.json OK')"
```

3つとも `exit=0` / `OK` になれば設置完了。本番の動作は、次に圧縮が起きたときに確認できる：

- 圧縮前に「会話スナップショットを保存しました: …」のシステムメッセージが出る
- 圧縮後の最初の応答に、圧縮前の生ログの内容が反映されている

すでに起動中の他のセッションには反映されない場合がある。新しく開くセッションから有効。

## 8. 運用上の注意（ユーザーに伝えること）

- **会話の全文が平文で残る。** 機微な情報を扱うセッションのログも保存される。保存先の管理はユーザーの責任範囲
- **容量は増え続ける。** 長い会話1件で数MBになる。削除の仕組みは含まれていないので、古いものは定期的に削る（例：`find ~/claude-session-notes -name "*.md" -mtime +30 -delete` を月1で実行、など）
- カスタマイズ可能な値：注入量（`MAX_CHARS`、既定4,000字）／鮮度（`PCC_MAX_AGE`、既定1,800秒）／保存先（`SESSION_NOTES_DIR`）

---

*この手順書は、実際に稼働している構成（スナップショット141件・自動/手動圧縮の両方で動作確認済み）から書き起こしたもの。2026-08-28作成。*
