Tech Blog

Copilot Chat と Claude Code の会話履歴を ChromaDB に投入して『過去の自分』を検索する

GitHub Copilot Claude Code ChromaDB Ollama RAG AIアシスタント Python MCP

🔗 シリーズ目次: 本記事は AIアシスタント運用ノート — Copilot / Claude Code を相棒として育てるための実践記録 シリーズの 実装編 (2) です。

この記事でわかること

  • VSCode Copilot ChatClaude Code が自動保存している会話ログ (JSONL) の 場所と構造
  • それぞれの JSONL 形式の違いと、共通インターフェースで吸収する設計
  • ChromaDB + Ollama でベクトル化して、「過去の自分」を自然言語で検索する仕組み
  • Claude Code の Stop フック を使って、セッション終了時に自動投入する手順
  • 数十セッション蓄積してみて分かった効果と注意点

対象読者

  • AI アシスタントとの会話を 永続記憶として活用 したい方
  • あの時どうやって解決したっけ」を毎回検索ノートに頼っている方
  • 自作 RAG に 会話ログ取り込み機能 を追加したい方
  • VSCode Copilot Chat / Claude Code どちらか、または両方を使っている方

動作環境

項目バージョン
OSWindows 11
Python3.13 (venv)
ChromaDB永続化モード (PersistentClient)
EmbeddingOllama nomic-embed-text (768次元)
VSCode Copilot Chattranscripts JSONL を workspaceStorage に自動保存
Claude Codeセッション JSONL を ~/.claude/projects/ に自動保存

1. はじめに — 「あの時の解決策、なんだったっけ」問題

AI アシスタントと毎日仕事をしていると、こんな瞬間が増えてきます。

あの時 Copilot が教えてくれた PowerShell の文字エンコーディング問題の解決策、なんだったっけ?

先週 Claude Code と議論して決めた「dev profile での認証バイパス設計」、結論はどっちだったっけ?

1か月前に踏んだあの罠、また踏みそうな気がする…

会話は JSONL ファイルとしてどこかに残っている はずですが、ファイル名は UUID で、内容は何百何千行もある JSON。grep で探すのも辛いし、そもそも「自然言語で過去の解決策を引きたい」というのが本来の欲求です。

ある日、私は気付きました。

会話ログは、毎日生成されている “RAG の最高の素材” じゃないか?

技術ブログ、設計ドキュメント、Stack Overflow の回答 — そういう “他人の知識” は世に溢れていますが、自分の文脈で・自分の言葉で・自分の問題に対して 出された解決策は、自分の会話ログにしかありません。これを RAG に流し込めば、文字通り 「過去の自分」を検索できる検索エンジン が作れる。

本記事は、その仕組みを Copilot Chat と Claude Code の両方 に対して作った話です。


2. 2種類のトランスクリプト

まず、それぞれがどこにどんな形式で保存しているか整理します。

2.1 VSCode Copilot Chat

保存先:

C:\Users\<user>\AppData\Roaming\Code\User\workspaceStorage\<workspace-hash>\GitHub.copilot-chat\transcripts\<uuid>.jsonl

ワークスペース(VSCode で開いているフォルダ)ごとにハッシュ化されたディレクトリがあり、その中に transcripts/ フォルダ が掘られて、UUID 名の JSONL が積まれていきます。

JSONL の構造(簡略化):

{"type":"user.message","data":{"content":"Java 21 で Virtual Threads どう書く?"},"timestamp":"..."}
{"type":"assistant.message","data":{"content":"`Thread.ofVirtual().start(() -> { ... })` で書けます..."},"timestamp":"..."}
{"type":"assistant.turn_end","data":{...}}

1行 = 1イベント。user / assistant のメッセージは type フィールドで識別でき、data.content に本文が文字列で入っています。シンプル

2.2 Claude Code

保存先:

C:\Users\<user>\.claude\projects\<project-slug>\<session-id>.jsonl

プロジェクト(リポジトリのフルパスを変換した slug)ごとにフォルダがあり、その中にセッションID名の JSONL があります。

JSONL の構造(簡略化):

{"type":"user","message":{"role":"user","content":[{"type":"text","text":"…"}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"…"},{"type":"thinking","thinking":"…"},{"type":"tool_use","name":"Read","input":{...}}]}}
{"type":"queue-operation","operation":"enqueue",...}
{"type":"attachment",...}
{"type":"file-history-snapshot",...}

こちらはやや複雑:

  • type フィールドは "user" / "assistant" だが、本文は message.content の配列 にネスト
  • 配列の各要素が コンテンツブロック で、type"text" / "thinking" / "tool_use" などに分かれる
  • ユーザーメッセージには <system-reminder> <ide_opened_file> 等の 自動挿入タグ が混ざる
  • queue-operation attachment file-history-snapshot のような メタイベント も同じ JSONL に同居

要するに、Copilot Chat は「テキスト1個 = 1メッセージ」のシンプル形式、Claude Code は「コンテンツブロック配列 + メタイベント混在」の構造化形式。同じ「会話履歴」と呼ばれていても、フォーマットは別物です。


3. 共通の課題と設計

両者をひとつのインターフェースで扱いたいので、こう整理しました。

共通の処理フロー

[JSONL ファイル]
   ↓ パース (フォーマット依存)
[user / assistant のテキストペア (= turns)]
   ↓ チャンク化 (共通)
[ChromaDB 用の id / text / metadata セット]
   ↓ upsert (共通)
[ChromaDB Collection]

フォーマット依存の部分(パース) だけを別実装にして、チャンク化以降は完全に共通化 する設計です。具体的には:

  • ingest_conversation.py — Copilot Chat 用パーサ + 共通投入処理を呼ぶ
  • ingest_claude_code.py — Claude Code 用パーサ + 同じ共通投入処理を呼ぶ
  • src/db/store.pyingest_chunks() — 両方から呼ばれる ChromaDB 投入ロジック

チャンク化のルール

会話の自然な単位は 「user 1 ターン + assistant 1 応答」のペア です。これを turns と呼びます。

一度に embedding する上限を nomic-embed-text の安全圏として MAX_CHUNK_CHARS = 1200 に置き、3ターンを1チャンク にまとめる方針にしました。

TURNS_PER_CHUNK = 3
MAX_CHUNK_CHARS = 1200

def turns_to_chunks(turns: list[dict], session_id: str, date_str: str) -> list[dict]:
    chunks = []
    for i in range(0, len(turns), TURNS_PER_CHUNK):
        group = turns[i:i + TURNS_PER_CHUNK]
        lines = []
        for t in group:
            lines.append(f"[ユーザー] {t['user']}")
            lines.append(f"[AI] {t['assistant']}")
            lines.append("")
        text = "\n".join(lines)
        if len(text) > MAX_CHUNK_CHARS:
            text = text[:MAX_CHUNK_CHARS]
        chunk_index = i // TURNS_PER_CHUNK
        chunks.append({
            "id": f"{session_id}::chunk_{chunk_index}",
            "text": text,
            "metadata": {
                "source_type": "conversation",
                "source_file": session_id,
                "title": f"会話セッション {date_str} (part {chunk_index + 1})",
                "date": date_str,
                "tags": "conversation,copilot",  # or "conversation,claude-code"
                "chunk_index": chunk_index,
            },
        })
    return chunks

メタデータの tags だけ「copilot」or「claude-code」で差別化し、それ以外は完全に同じ形にしました。これで rag search した時に、Copilot との会話も Claude Code との会話も区別なく 検索結果に並びます。


4. Copilot Chat の取り込み

Copilot Chat 用 (ingest_conversation.py) の核心はこのパーサです。

def parse_conversation(jsonl_path: Path) -> list[dict]:
    """JSONL からユーザー/AI のメッセージペアを抽出する"""
    turns = []
    user_msg = None

    with open(jsonl_path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                record = json.loads(line)
            except json.JSONDecodeError:
                continue

            rtype = record.get("type", "")
            timestamp = record.get("timestamp", "")

            if rtype == "user.message":
                content = record["data"].get("content", "").strip()
                if content:
                    user_msg = {"content": content, "timestamp": timestamp}

            elif rtype == "assistant.message":
                content = record["data"].get("content", "").strip()
                if content and user_msg:
                    turns.append({
                        "user": user_msg["content"],
                        "assistant": content,
                        "timestamp": user_msg["timestamp"],
                    })
                    user_msg = None

    return turns

user.message → assistant.message という流れを順番に拾って turns に積むだけ。type で判別できるシンプルさのおかげで、パーサが30行に収まる。Copilot Chat 側の素直さに感謝しました。

最新トランスクリプトの自動検出も簡単:

TRANSCRIPT_BASE = Path(r"C:\Users\<user>\AppData\Roaming\Code\User\workspaceStorage")

def find_latest_transcript() -> Path:
    """全ワークスペースを横断して最新のトランスクリプトを自動検出する"""
    candidates = []
    if TRANSCRIPT_BASE.exists():
        for jsonl in TRANSCRIPT_BASE.rglob("*.jsonl"):
            if "transcripts" in jsonl.parts:
                candidates.append(jsonl)
    if not candidates:
        raise FileNotFoundError(...)
    return max(candidates, key=lambda p: p.stat().st_mtime)

rglob で全ワークスペースを再帰検索して、mtime が一番新しい JSONL を返す。これを rag conv コマンド経由でユーザーが呼べるようにしました。

# 最新の会話を1コマンドで投入
rag conv

# 過去の特定セッションを指定して投入
rag conv "C:\path\to\specific.jsonl"

5. Claude Code の取り込み — ノイズと格闘する

ここからが少し面白いです。Claude Code は 会話以外のメタイベントが大量に混じっている ので、フィルタが必要です。

5.1 取り出すべきもの・捨てるべきもの

type何か取り込むか
userユーザーメッセージ✅ ただし content 配列から text ブロックのみ抽出
assistantAI 応答✅ 同様に text ブロックのみ抽出
queue-operationキュー操作(内部処理イベント)
attachmentファイル添付情報
file-history-snapshotファイル履歴
ai-titleセッションタイトル付与
last-prompt最後のプロンプトメタ

5.2 コンテンツブロック配列の処理

ユーザー / AI どちらのメッセージも message.content は配列で、ブロックの種類が混在します。

def _extract_text_blocks(content) -> str:
    """message.content から text ブロックのみ連結して返す"""
    if isinstance(content, str):
        return content.strip()
    if not isinstance(content, list):
        return ""
    parts = []
    for block in content:
        if not isinstance(block, dict):
            continue
        if block.get("type") == "text":
            txt = block.get("text", "")
            if isinstance(txt, str) and txt.strip():
                parts.append(txt.strip())
    return "\n\n".join(parts)

text 以外のブロック(thinking / tool_use / tool_result)はスキップ します。thinking は AI の内部思考なので会話の流れには含めない方が検索ノイズが少なく、tool_use は構造化された JSON なのでテキスト検索に向かないからです。

5.3 自動挿入タグの除去

ユーザーメッセージには Claude Code が自動挿入するタグが混ざります。

<system-reminder>The TodoWrite tool hasn't been used recently...</system-reminder>
<ide_opened_file>The user opened the file ...</ide_opened_file>
<command-message>...</command-message>

これらが残ったまま RAG に入ると、検索したときに「あの会話で何を話したか」より タグの中身(一般的なノイズ) がヒットしてしまう。なので除去します。

NOISE_TAG_RE = re.compile(
    r"<(system-reminder|ide_opened_file|ide_selection|command-message|command-name|command-args|local-command-stdout)>.*?</\1>",
    re.DOTALL,
)

def _strip_noise(text: str) -> str:
    if not text:
        return ""
    cleaned = NOISE_TAG_RE.sub("", text)
    cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)  # 空行を整理
    return cleaned.strip()

DOTALL で改行を跨いで一気に削る。これだけで 検索精度が体感で3割上がりました

5.4 パーサ本体

これらを組み合わせると、Claude Code 用のパーサは次のようになります。

def parse_conversation(jsonl_path: Path) -> list[dict]:
    turns: list[dict] = []
    pending_user: dict | None = None

    with open(jsonl_path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                record = json.loads(line)
            except json.JSONDecodeError:
                continue

            rtype = record.get("type")
            msg = record.get("message", {}) or {}

            if rtype == "user":
                content = msg.get("content") if isinstance(msg, dict) else None
                text = _strip_noise(_extract_text_blocks(content))
                if text:
                    pending_user = {"content": text, "timestamp": record.get("timestamp", "")}

            elif rtype == "assistant":
                content = msg.get("content") if isinstance(msg, dict) else None
                text = _extract_text_blocks(content)  # AI 応答はノイズ除去不要
                if text and pending_user:
                    turns.append({
                        "user": pending_user["content"],
                        "assistant": text,
                        "timestamp": pending_user["timestamp"],
                    })
                    pending_user = None

    return turns

Copilot 用と同じ「ペアを積む」ロジックですが、ブロック抽出とノイズ除去が前段に挟まる のがポイントです。


6. Claude Code は Stop フックで自動投入

Copilot Chat 用は rag conv を手動実行する運用ですが、Claude Code には Stop フック という仕組みがあります。セッションが終了した瞬間に任意のコマンドを実行できる。これを使えば 完全自動化 できます。

~/.claude/settings.json にこう書きます。

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"C:\\Users\\<user>\\git\\my-rag-brain\\scripts\\claude_code_stop_hook.ps1\""
          }
        ]
      }
    ]
  }
}

呼ばれる PowerShell スクリプトはこんな感じです。

# stdin から JSON を読み取り、transcript_path を取り出して ingest を起動
$stdin = [Console]::In.ReadToEnd()
$payload = $stdin | ConvertFrom-Json
$transcript = $payload.transcript_path

if (-not (Test-Path $transcript)) { return }

# バックグラウンドで起動 (Stop フックを長時間ブロックしない)
$env:PYTHONIOENCODING = 'utf-8'
Start-Process -FilePath "<venv>\python.exe" `
    -ArgumentList @("<my-rag-brain>\src\pipeline\ingest_claude_code.py", $transcript) `
    -WindowStyle Hidden `
    -RedirectStandardOutput $stdoutLog `
    -RedirectStandardError $stderrLog

ポイントは2つ:

  1. stdin から transcript_path を読み取る — Claude Code が JSON で渡してくれる
  2. Start-Process でバックグラウンド起動 — Stop フックを長時間ブロックしないため

これで Claude Code を閉じた瞬間に、そのセッションが ChromaDB に自動投入 されます。手動でやることは何もない。


7. 何が変わったか — 「過去の自分」を検索できる安心感

数十セッション蓄積した結果、こんなことが起きるようになりました。

7.1 「あれどうやって解決したっけ」問題が消えた

PowerShell で文字エンコーディングのエラーに踏んだ時:

rag search "PowerShell UTF-8 文字化け"

すると、過去に同じ問題を Copilot や Claude Code と議論して出した解決策 が、上位5件で返ってきます。「ああ、PYTHONIOENCODING=utf-8 を環境変数に入れたんだった」と一瞬で思い出せる。

7.2 議論の結論が永続化される

設計判断や運用方針について議論したセッションは、結論部分の前後3ターンが1チャンクとして保存 されます。後で「あの設計判断、決め手は何だったっけ」と検索すると、議論の流れごと出てくる。設計ドキュメントには書ききれない “なぜそうしたか” の文脈 が残るのが大きい。

7.3 同じミスを踏みにくくなる

「過去の自分」が踏んだ罠は、不思議と現在の自分に近いところに潜んでいます。新しいタスクを始める前に rag search "<関連キーワード>" で過去ログを引くと、忘れていた注意点 が思い出されることがしばしばあります。

特に Claude Code の Stop フック自動投入を入れてからは、意識せずとも記憶が積み上がる ので、検索の手応えがどんどん良くなっています。


8. 注意点・限界

8.1 機密情報の混入リスク

会話には コードの抜粋・API キー・URL・個人情報 が混ざることがあります。ローカルの ChromaDB に閉じている分には問題ありませんが、ChromaDB のディレクトリを Git で公開リポジトリにコミットしない ことは必須です。私は .gitignorechroma_db/ を入れています。

8.2 ノイズ除去ルールはメンテが要る

Claude Code の自動挿入タグは将来増える可能性があります。今回 NOISE_TAG_RE で除去しているタグ一覧は、私が観測した範囲のもの。新しいタグが出てきたら追加する という運用コストがあります。

8.3 古い会話の embedding は再生成しないと精度が劣化する可能性

Ollama の embedding モデルをアップデートすると、古い chunk の embedding と新しい chunk の embedding が 微妙に違う空間 に乗ります。完全な互換性を保つには 全件再 embedding が必要。これは rag bulk で全件投入し直す運用にしています。

8.4 「全部投入する」と検索精度が下がる場合がある

意外な落とし穴ですが、ノイズだらけの初期セッションエラーで終わったセッション を含めると検索精度が下がります。source_file メタデータでフィルタしたり、source_type を細かく分けたりして、検索クエリ側で絞れるようにしています。


9. まとめ

  • VSCode Copilot ChatworkspaceStorage/.../transcripts/*.jsonl に、Claude Code~/.claude/projects/<slug>/<session-id>.jsonl に会話履歴を自動保存している
  • 両者の JSONL は 構造が違う(Copilot はシンプル、Claude Code はブロック配列 + メタイベント混在)が、パーサだけ別実装 + チャンク化以降は共通化 で吸収できる
  • ノイズ除去<system-reminder> 等のタグ削除)が体感3割の検索精度差を生んだ
  • Claude Code 側は Stop フック + バックグラウンド起動 で完全自動化できる
  • 数十セッション蓄積すると、「過去の自分」を自然言語で検索できる状態が手に入る。あの解決策・あの判断・あの罠を即座に思い出せる
  • 機密情報の扱い、ノイズ除去ルールのメンテ、embedding モデル更新時の再生成 — 運用コストはあるが、得られるリターンの方が圧倒的に大きい

関連記事:

AI が忘れても、私が記録しておく。そして、必要な時にこちら側から「思い出させる」。それが、いま私が AI と仕事をするときの基本姿勢 になっています。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。