fujitani soraです。

AI Agentの過去の会話を検索するCLI「ccsession」を開発しています。

以前の記事ではClaude Codeの履歴検索を紹介しました。複数のAgentに対応する中で扱った、セッションデータの構造と、Goでの抽象化について書きます。

Claude CodeとCodexは、どちらもJSONLで会話を保存します。ただ、発言者や本文が入っている場所は異なります。Piは同じファイルの中に会話の分岐を持ち、OpenCodeではDBの複数レコードから1つの発言を組み立てます。

これらを横断して検索・表示するなら、各Agentのデータを読み、用途に合わせた共通の型へ変換する処理が必要です。

Agent固有の保存データ
  ↓ AgentごとのGoのstructで受け取る
本文・発言者・時刻を取り出す
  ↓ 共通のMessageへ変換する
検索・表示

ccsessionは、この変換の実例として参照します。対象はcommit 14b3b13です。公式ドキュメントで確認できる内容と、この実装が読み取る内部形式を区別して説明します。保存形式はバージョンによって変わります。

各Agentのデータ構造

Agent保存形式Goで扱うときのポイント
Claude CodeJSONLmessage.contentが文字列またはblock配列
CodexJSONL外側のtypeによってpayloadの構造が変わる
Pi / Oh My PiJSONLメッセージやメタデータに加え、entry間の親子関係がある
GrokメタデータJSONと会話の更新ログchunk単位のイベントを発言へ変換する
OpenCodeSQLitemessageとpartなど、複数の行から発言を作る
Kiro保存世代によってSQLite / JSON / JSONL同じAgentでも世代別の入力型が必要

以下は、同じ「テストの失敗を調べて」という発言が、それぞれどこに入るかを示した例です。架空のデータから必要なフィールドだけを載せています。JSONLも読みやすいように改行していますが、実際のファイルでは1レコードを1行に保存します。

Goの型は説明用に名前を付け、フィールドを絞ったものです。ccsessionからそのまま転載したコードではありません。各節に対応する実装へのリンクを付けています。

Claude Code

公式ドキュメントでは、transcriptの保存先を~/.claude/projects/<project>/<session-id>.jsonlとしています。各行には、メッセージ、tool use、メタデータなどが入ります。

userの発言は、次のような構造です。

{
  "type": "user",
  "timestamp": "2026-09-01T10:00:01Z",
  "message": {
    "role": "user",
    "content": "テストの失敗を調べて"
  }
}

Goでは、外側のレコードと内側のmessageを分けて受け取れます。

type ClaudeEntry struct {
    Type      string         `json:"type"`
    Timestamp string         `json:"timestamp"`
    Message   *ClaudeMessage `json:"message"`
}

type ClaudeMessage struct {
    Role    string          `json:"role"`
    Content json.RawMessage `json:"content"`
}

json:"message"などのstruct tagは、GoのフィールドとJSONのキーの対応をencoding/jsonに伝えます。tagの文字列自体にdecodeの機能があるわけではなく、標準ライブラリがそれを解釈します。

Messageをポインタにすると、ゼロ値のClaudeEntryへdecodeしたとき、messageの欠落やnullをnilで扱えます。メタデータの行を読む際に、メッセージがあるかどうかを判定できます。欠落とnullを区別する型ではありません。

本文のContentはstringにしていません。文字列のほかに、次のようなblock配列も扱うためです。

[
  {
    "type": "text",
    "text": "テストの失敗を調べて"
  }
]

ここで使うjson.RawMessageは、JSON値をバイト列として保持し、decodeを遅らせるための型です。外側をstructへdecodeした後に、contentを文字列として読むか、block配列として読むかを決められます。

読み取り処理では、会話の行を選び、contentが文字列ならその値を、配列ならtext blockの本文を取り出します。この例から得る発言者はuser、本文は「テストの失敗を調べて」です。時刻は外側のtimestampを使います。

ここでは外側のtypeから発言者を判定します。読み取り実装の例でも、この値を使っています。

Codex

Codexはセッションログをrolloutと呼び、JSONLで保存します。公式リポジトリのrecorderが保存処理を持っています。

Codex rust-v0.63.0のRolloutItem定義では、typeとpayloadを持ち、セッション情報や発言などを区別する形式を確認できます。このバージョンは形式の参照元であり、ccsessionの対応バージョンを限定するものではありません。

userの発言は、次のようにresponse_itemの中に入ります。

{
  "type": "response_item",
  "timestamp": "2026-09-01T10:00:01Z",
  "payload": {
    "type": "message",
    "role": "user",
    "content": [
      {
        "type": "input_text",
        "text": "テストの失敗を調べて"
      }
    ]
  }
}

外側のtypeと、payload.typeは別の情報です。外側がsession_metaならpayloadにはセッションIDやCWDが入り、response_itemならメッセージなどの応答項目が入ります。

Goでは、payloadを一度RawMessageで受け取ります。

type CodexEntry struct {
    Type      string          `json:"type"`
    Timestamp string          `json:"timestamp"`
    Payload   json.RawMessage `json:"payload"`
}

type CodexMessage struct {
    Type    string          `json:"type"`
    Role    string          `json:"role"`
    Content json.RawMessage `json:"content"`
}

まずCodexEntryへdecodeし、外側のTypeを確認します。response_itemならpayloadをCodexMessageへdecodeし、内側のTypeがmessageか、Roleが表示対象かを確認します。contentからinput_text・output_textの本文を取り出すと、この例はuserの「テストの失敗を調べて」になります。

type: response_item
  → payload.type: message
    → payload.role: user
      → payload.content[].text

switch entry.Typeは文字列に対するswitchです。Goのtype switchではありません。また、RawMessageはタグとpayloadの対応を型で保証しないため、タグの判定と内側のdecodeを両方行います。

Pi / Oh My Pi

PiのSession File Formatでは、セッションをJSONLで保存し、entry同士をidとparentIdでつなぎます。1つのファイルの中で、過去の発言から会話を分岐できます。

メッセージのentryは次の形です。

{
  "type": "message",
  "id": "a1b2c3d4",
  "parentId": null,
  "timestamp": "2026-09-01T10:00:01Z",
  "message": {
    "role": "user",
    "content": "テストの失敗を調べて"
  }
}

会話を取り出すための入力型と、分岐を追うための情報を合わせると、Goでは次のように表せます。

type PiEntry struct {
    Type      string          `json:"type"`
    ID        string          `json:"id"`
    ParentID  *string         `json:"parentId"`
    Timestamp string          `json:"timestamp"`
    Message   json.RawMessage `json:"message"`
}

type PiMessage struct {
    Role    string          `json:"role"`
    Content json.RawMessage `json:"content"`
}

外側のTypeがmessageなら、内側をPiMessageへdecodeします。contentの文字列・block配列の扱いはClaude Codeと共通にできます。この例からも、発言者userと本文「テストの失敗を調べて」を取り出せます。

一方、会話の分岐は別途扱う必要があります。ファイル順にメッセージを並べる処理と、選択した末端のentryからparentIdをたどる処理では、得られる会話が異なります。モデルに渡すcontextの復元には、分岐に加えてcompactionなどの解釈も必要です。

上のParentIDは分岐を扱うためのフィールドです。本文だけの共通型へ変換するなら、分岐関係は失われます。

Oh My Piの仕様もJSONLと親子関係を持ちます。メッセージの形は近い一方、表示名にはtitleやtitle_changeを使います。Piのsession_info.nameとの違いはメタデータのreaderで処理し、本文の読み取りは共有できます。

Grok

Grok Buildの公式ドキュメントでは、セッションごとにメタデータのsummary.json、会話の更新ログupdates.jsonl、モデル向けのchat_history.jsonlなどを保存します。resume・restoreに使う会話ログはupdates.jsonlです。

更新ログでは、userの発言が次のようなイベントに入ります。この例ではtimestampを省略しています。

{
  "params": {
    "update": {
      "sessionUpdate": "user_message_chunk",
      "content": {
        "type": "text",
        "text": "テストの失敗を調べて"
      }
    }
  }
}

Goでも、この入れ子に対応するstructで受け取ります。短い中間構造には無名structを使えます。

type GrokUpdate struct {
    Timestamp int64 `json:"timestamp"`
    Params    struct {
        Update struct {
            SessionUpdate string `json:"sessionUpdate"`
            Content       struct {
                Type string `json:"type"`
                Text string `json:"text"`
            } `json:"content"`
        } `json:"update"`
    } `json:"params"`
}

sessionUpdateがuser_message_chunkならuser、agent_message_chunkならassistantとして扱い、content.textから本文を取ります。この例はuserの「テストの失敗を調べて」になります。

chunkは発言全体とは限りません。ccsessionの更新ログreaderでは、連続するassistantのchunkを1つの本文へつなげています。

ここではJSONをdecodeするだけでなく、イベントを発言単位へまとめる処理が必要です。どこで発言を区切るかは、読み取り側で決める変換規則になります。

OpenCode

OpenCodeのドキュメントでは、通常のDB保存先として~/.local/share/opencode/opencode.dbを案内しています。

会話の読み方はDBのschemaによります。ここでは、ccsessionが対応するmessageとpartの関係を例にします。1つのmessageに、本文などのpartが紐づきます。

下記はDBの行をJSONで表した模式図で、保存ファイルそのものではありません。JSON列のdataも展開しています。

{
  "message": {
    "id": "msg-1",
    "data": {
      "role": "user"
    }
  },
  "parts": [
    {
      "message_id": "msg-1",
      "data": {
        "type": "text",
        "text": "テストの失敗を調べて"
      }
    }
  ]
}

Goでは、SQLで取得した行のうち、JSON列の内容をstructへdecodeします。

type OpenCodeMessageData struct {
    Role string `json:"role"`
    Time struct {
        Created int64 `json:"created"`
    } `json:"time"`
}

type OpenCodePartData struct {
    Type string `json:"type"`
    Text string `json:"text"`
}

message_idでpartをmessageに対応付け、message.data.roleを発言者にします。partのうちTypeがtextの本文を集めると、この例はuserの「テストの失敗を調べて」になります。複数のtext partがあれば、それらを結合して1つの発言を作ります。

読み取り実装の例では、SQLでmessageとpartを取得し、Go側で発言を組み立てています。このテーブル構造は、全バージョンに共通するとは限りません。

ファイルを1行ずつ読むAgentと同じなのは、変換後の発言データです。入力を読み出す操作までJSONLに合わせる必要はありません。

Kiro

Kiro CLIの公式ドキュメントには、セッションの自動保存、IDによる再開、exportなどが載っています。内部形式については、ccsessionが読み取る保存世代を分けて見ます。

ccsessionのreaderは、classicのSQLite、v2のsessions/cli/、v3のsessions/<bucket>/<session>/を扱います。v3のmessages.jsonlでは、発言は次の形です。

{
  "timestamp": "2026-09-01T10:00:01Z",
  "payload": {
    "type": "user",
    "content": "テストの失敗を調べて"
  }
}

この入力に対応するGoの型は、次のようになります。

type KiroV3Entry struct {
    Timestamp string `json:"timestamp"`
    Payload   struct {
        Type    string `json:"type"`
        Content string `json:"content"`
    } `json:"payload"`
}

payload.typeを発言者、payload.contentを本文に変換すると、userの「テストの失敗を調べて」になります。

Codexにもpayloadがありますが、Codexのpayload.type: "message"は項目の種類、Kiro v3のpayload.type: "user"は発言者です。キー名が同じでも意味は異なるため、同じGoのstructを使う理由にはなりません。

v2はkindやdata.contentを持つ別の形です。ccsessionのメッセージreaderも、v2とv3でdecodeを分けています。Agent名だけでなく、保存世代も入力型を分ける基準になります。これらの内部形式は、公式に安定性が保証された外部APIとは区別しています。

Goで共通化する

各Agentから取り出したデータを、発言者・時刻・本文の共通型へ変換します。

type Message struct {
    Role      string
    Timestamp time.Time
    Body      string
}

入力型には各Agentのフィールド名を使い、出力型には用途に必要な情報を持たせます。

各入力と共通型の対応を並べると、次のようになります。

AgentRoleの取得・変換Bodyの取得・変換
Claude Code外側のtypeから会話の行を選ぶmessage.contentの文字列またはtext block
Codexmessageに絞ったうえでpayload.rolepayload.contentのtext系block
Pi / Oh My Pimessage.rolemessage.contentの文字列またはtext block
GroksessionUpdateから発言者へ変換content.textを必要に応じてchunk結合
OpenCodemessageのJSON列にあるrole紐づくtext partを結合
Kiro v3payload.typepayload.content

時刻も、文字列やepoch値のまま表示側へ渡さず、readerでtime.Timeへ変換します。秒とミリ秒の違いや、時刻が記録されていない場合の扱いもここで決めます。不明な時刻をゼロ値で表すか、ポインタなどで明示するかは、利用側に必要な区別に合わせます。

共通の型を作るときは、元データを戻せるかも確認しておきます。このMessageには画像、tool call、分岐関係、token usageがありません。本文の検索や表示には使えますが、これだけでAgentのcontextを復元することはできません。

分岐を表示するならIDと親子関係、ツール実行を分析するならtool callとresultの対応が必要です。扱いたい情報を決めてから、共通の型に残すフィールドを選びます。

interface

利用側が必要とするのが会話の一覧なら、readerのinterfaceは次の形にできます。

type MessageReader interface {
    ReadMessages() ([]Message, error)
}

ファイルパスやDB接続は各readerが持ちます。利用側は、保存形式を意識せずに読み取れます。

// 各readerがReadMessages() ([]Message, error)を実装している場合
var reader MessageReader = &ClaudeReader{Path: "session.jsonl"}
messages, err := reader.ReadMessages()

// DBを読むreaderにも差し替えられる
reader = &OpenCodeReader{DB: db}
messages, err = reader.ReadMessages()

Goではimplements宣言は不要です。必要なメソッドがあればinterfaceを満たします。ポインタreceiverで実装した場合は、例のように&ClaudeReader{...}を渡します(method setの規則)。

入力の変更とエラー

json.Unmarshalで、次の型に読み込む場合を考えます。

var input struct {
    Role string `json:"role"`
}
err := json.Unmarshal(data, &input)
入力JSON結果
{"role":"user","extra":1}Roleは"user"。未知のextraは無視
{}Roleはゼロ値の""
{"role":123}stringに数値を入れられずエラー

decodeの成功だけでは不十分で、roleや本文も確認します。readerの戻り値では、検索対象外と読み取り失敗を区別します。

状況扱い
画像だけの発言本文検索の対象外としてスキップ
ファイルを開けない・JSONが壊れているエラーを返す

失敗まで空のMessageにすると、利用側が「会話がない」と「読めない」を区別できなくなります。

テスト

同じ「こんにちは」というuser発言を、Claude Code・Codex・PiそれぞれのJSONで用意します。入力と変換関数を組にして、同じMessageになることを確認するテーブル駆動テストです。

claudeJSON・codexJSON・piJSONは各節で示した構造のfixture、decodeClaude・decodeCodex・decodePiは対応する変換関数です。本文にはテスト部分だけを載せています。fixtureと変換関数を含むサンプルは、ccsessionに依存しない1ファイルです。例を絞るため、MessageはRoleとBodyだけを持ち、時刻や分岐は扱いません。

func TestNormalize(t *testing.T) {
    cases := []struct {
        name   string
        raw    string
        decode func([]byte) (Message, error)
    }{
        {"Claude Code", claudeJSON, decodeClaude},
        {"Codex", codexJSON, decodeCodex},
        {"Pi", piJSON, decodePi},
    }
    want := Message{Role: "user", Body: "こんにちは"}
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got, err := tc.decode([]byte(tc.raw))
            if err != nil {
                t.Fatal(err)
            }
            if got != want {
                t.Errorf("got %#v, want %#v", got, want)
            }
        })
    }
}

サンプルをnormalize_test.goとして保存し、Go環境で次を実行すると、Agent別に3つのサブテストが動きます。

go test normalize_test.go -v

入力の形は異なっても、変換後の期待値は共通です。ここでは正常系だけを確認しています。画像だけの入力や未知のイベントを扱う場合は、対象外として除くのか、エラーを返すのかを決め、その規則をテストへ追加します。

まとめ

Agentのセッションデータは、入れ子のJSON、chunk単位のイベント、木構造、DBの関連レコードなど、形も意味も異なります。

Goでは、Agentや保存世代ごとのstructで入力を受け取り、形が変わる部分をRawMessageで扱えます。そこから必要な情報を共通のMessageへ変換し、読み取り操作をinterfaceとして利用側へ渡します。

入力型はAgentの仕様に合わせ、出力型は読みたい情報に合わせる。この境界を分けることで、Agentごとの違いをreaderに収めながら、検索や表示の処理を共有できます。