AI Agentの過去の会話を検索するCLI「ccsession」を開発しています。
以前の記事ではClaude Codeの履歴検索を紹介しました。複数のAgentに対応する中で扱った、セッションデータの構造と、Goでの抽象化について書きます。
GitHub - sorafujitani/ccsession: pick any past Claude Code session via fzf, then resume it from the right cwd
pick any past Claude Code session via fzf, then resume it from the right cwd - sorafujitani/ccsession
GitHub
Claude CodeとCodexは、どちらもJSONLで会話を保存します。ただ、発言者や本文が入っている場所は異なります。Piは同じファイルの中に会話の分岐を持ち、OpenCodeではDBの複数レコードから1つの発言を組み立てます。
これらを横断して検索・表示するなら、各Agentのデータを読み、用途に合わせた共通の型へ変換する処理が必要です。
Agent固有の保存データ
↓ AgentごとのGoのstructで受け取る
本文・発言者・時刻を取り出す
↓ 共通のMessageへ変換する
検索・表示
ccsessionは、この変換の実例として参照します。対象はcommit 14b3b13です。公式ドキュメントで確認できる内容と、この実装が読み取る内部形式を区別して説明します。保存形式はバージョンによって変わります。
各Agentのデータ構造
| Agent | 保存形式 | Goで扱うときのポイント |
|---|---|---|
| Claude Code | JSONL | message.contentが文字列またはblock配列 |
| Codex | JSONL | 外側のtypeによってpayloadの構造が変わる |
| Pi / Oh My Pi | JSONL | メッセージやメタデータに加え、entry間の親子関係がある |
| Grok | メタデータJSONと会話の更新ログ | chunk単位のイベントを発言へ変換する |
| OpenCode | SQLite | messageとpartなど、複数の行から発言を作る |
| Kiro | 保存世代によってSQLite / JSON / JSONL | 同じAgentでも世代別の入力型が必要 |
以下は、同じ「テストの失敗を調べて」という発言が、それぞれどこに入るかを示した例です。架空のデータから必要なフィールドだけを載せています。JSONLも読みやすいように改行していますが、実際のファイルでは1レコードを1行に保存します。
Goの型は説明用に名前を付け、フィールドを絞ったものです。ccsessionからそのまま転載したコードではありません。各節に対応する実装へのリンクを付けています。
Claude Code
Overview - Claude Code Docs
Claude Code is an agentic coding tool that reads your codebase, edits files, runs commands, and integrates with your development tools. Available in your terminal, IDE, desktop app, and browser.
Claude Code Docs
公式ドキュメントでは、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
GitHub - openai/codex: Lightweight coding agent that runs in your terminal
Lightweight coding agent that runs in your terminal - openai/codex
GitHub
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
GitHub - earendil-works/pi: AI agent toolkit: unified LLM API, agent loop, TUI, coding agent CLI
AI agent toolkit: unified LLM API, agent loop, TUI, coding agent CLI - earendil-works/pi
GitHub
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は分岐を扱うためのフィールドです。本文だけの共通型へ変換するなら、分岐関係は失われます。
GitHub - can1357/oh-my-pi: ⌥ Coding agent with the IDE wired in. Built by Stencil Labs.
⌥ Coding agent with the IDE wired in. Built by Stencil Labs. - can1357/oh-my-pi
GitHub
Oh My Piの仕様もJSONLと親子関係を持ちます。メッセージの形は近い一方、表示名にはtitleやtitle_changeを使います。Piのsession_info.nameとの違いはメタデータのreaderで処理し、本文の読み取りは共有できます。
Grok
GitHub - xai-org/grok-build: SpaceXAI's coding agent harness and TUI. Fullscreen, mouse interactive, extensible.
SpaceXAI's coding agent harness and TUI. Fullscreen, mouse interactive, extensible. - xai-org/grok-build
GitHub
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 | The open source AI coding agent
OpenCode - The open source coding agent.
opencode.ai
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
CLI - Docs - Kiro
Command-line interface for Kiro - build, test, and deploy from anywhere
kiro.dev

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のフィールド名を使い、出力型には用途に必要な情報を持たせます。
各入力と共通型の対応を並べると、次のようになります。
| Agent | Roleの取得・変換 | Bodyの取得・変換 |
|---|---|---|
| Claude Code | 外側のtypeから会話の行を選ぶ | message.contentの文字列またはtext block |
| Codex | messageに絞ったうえでpayload.role | payload.contentのtext系block |
| Pi / Oh My Pi | message.role | message.contentの文字列またはtext block |
| Grok | sessionUpdateから発言者へ変換 | content.textを必要に応じてchunk結合 |
| OpenCode | messageのJSON列にあるrole | 紐づくtext partを結合 |
| Kiro v3 | payload.type | payload.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に収めながら、検索や表示の処理を共有できます。