Class: Agent::Sessions::Readers::Claude

Inherits:
Base
  • Object
show all
Defined in:
lib/agent/sessions/readers/claude.rb

Overview

Claude Code transcripts. Written against 142 real transcripts, 29,688 records, inventoried 2026-08-12.

The content vocabulary is a straight match for this gem's: text, thinking, tool_use, tool_result and image are exactly the five part types the design doc names, so nothing here has to invent a mapping. What Claude adds is everything around the conversation — a third of all records are session state, and two more kinds carry context the model saw without being a turn anyone took.

Constant Summary collapse

NON_MESSAGE_TYPES =

State, not conversation, and together 11,000+ of the records written. Skipped in silence: warning about a record deliberately classified would teach a caller that warnings are noise.

atis-latch and bridge-session postdate the corpus above — found by running this reader over a live 2026-08-24 transcript and reading its own warnings (23 and 17 records), the same way Codex's tool list grew. Both are session plumbing: a latch marker, and the record tying a local transcript to its cloud session id (bridgeSessionId, owner uuids). Neither is a turn anyone took.

%w[ai-title mode permission-mode agent-name last-prompt
file-history-snapshot file-history-delta queue-operation
pr-link summary atis-latch bridge-session].freeze
EVENT_TYPES =

Context the model saw, but not a turn: system is turn_duration, stop_hook_summary, away_summary, local_command; attachment is hook output, skill listings, task reminders, pasted files. Same judgement Codex's event_msg gets — available on request, never on by default.

%w[system attachment].freeze
CONTENT_PARTS =
{ "text" => :text, "thinking" => :thinking, "tool_use" => :tool_use,
"tool_result" => :tool_result, "image" => :image }.freeze
SPILL =

How Claude Code points at output too large to inline. It is prose, not a structured field — 24 real tool_result parts and 149 attachments carry this sentence — so the path has to be matched out of the text.

/Full output saved to:\s*(\S+)/
MAX_SPILL_BYTES =

A spilled file is read whole. The largest observed is well under this; the cap exists because the pointer says nothing about the size.

4_000_000

Constants inherited from Base

Base::MAX_RECORD_BYTES

Instance Attribute Summary

Attributes inherited from Base

#session

Instance Method Summary collapse

Methods inherited from Base

#compactions, #each_message, #fidelity, #messages, #partial?, #tree, #warnings

Constructor Details

#initialize(session, resolve_spills: true, **rest) ⇒ Claude

Returns a new instance of Claude.



55
56
57
58
# File 'lib/agent/sessions/readers/claude.rb', line 55

def initialize(session, resolve_spills: true, **rest)
  super(session, **rest)
  @resolve_spills = resolve_spills
end

Instance Method Details

#branching?Boolean

Every uuid-bearing record names the record it followed, and 380 branch points sit across 85 of 151 real transcripts — a turn edited and re-run leaves two children under one parent. Exactly one root per file and no orphaned parent link was found in that corpus, so the links are trustworthy enough to build a tree from.

Returns:

  • (Boolean)


53
# File 'lib/agent/sessions/readers/claude.rb', line 53

def branching? = true

#subagentsObject

The transcripts of agents this session spawned, as readers of their own. Exposed rather than inlined, per design doc 8.1: a subagent's turns are not the parent's turns, and merging them would break every count taken from this reader. 124 of these sit beside real sessions on this machine.

isSidechain is false on all 22,072 records in the main transcripts, so there is nothing to filter out there — the separation is already how Claude Code writes them.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/agent/sessions/readers/claude.rb', line 68

def subagents
  entries = begin
    Dir.children(File.join(sidecar_root, "subagents"))
  rescue SystemCallError
    return []
  end

  entries.sort.filter_map do |name|
    next unless File.extname(name) == ".jsonl"

    child = child_session(File.join(sidecar_root, "subagents", name))
    child && self.class.new(child, resolve_spills: @resolve_spills, include_events: include_events)
  end
end

#usageObject

Session totals, summed over assistant records but deduplicated by message.id first — and the dedup is most of the number. One API response streams into one record PER CONTENT BLOCK, each carrying the same message.id and the same usage: in one real transcript on this machine (2026-08-24), 260 assistant records share 124 message ids, 94 of which repeat with byte-identical usage. A naive sum reports roughly double what Anthropic billed. An id-less record (not observed, but rule 2 says formats drift) is counted rather than dropped: overcounting a novelty beats silently ignoring it.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/agent/sessions/readers/claude.rb', line 92

def usage
  seen = {}
  total = nil
  each_record do |record, _line_number|
    usage = usage_from(record)
    next unless usage

    id = record.dig("message", "id")
    next if id && seen[id]

    seen[id] = true if id
    total = total ? total + usage : usage
  end
  total
end