agent_sessions 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/exe/agent-sessions +8 -0
- data/lib/agent/sessions/adapters/amp.rb +162 -0
- data/lib/agent/sessions/adapters/base.rb +259 -0
- data/lib/agent/sessions/adapters/claude.rb +123 -0
- data/lib/agent/sessions/adapters/codex.rb +121 -0
- data/lib/agent/sessions/adapters/copilot.rb +128 -0
- data/lib/agent/sessions/adapters/cursor.rb +176 -0
- data/lib/agent/sessions/adapters/cursor_ide.rb +136 -0
- data/lib/agent/sessions/adapters/enumeration.rb +252 -0
- data/lib/agent/sessions/adapters/gemini.rb +133 -0
- data/lib/agent/sessions/adapters/grok.rb +122 -0
- data/lib/agent/sessions/adapters/opencode.rb +322 -0
- data/lib/agent/sessions/adapters/pi.rb +185 -0
- data/lib/agent/sessions/adapters/qwen.rb +52 -0
- data/lib/agent/sessions/audit.rb +71 -0
- data/lib/agent/sessions/check.rb +9 -0
- data/lib/agent/sessions/cli.rb +532 -0
- data/lib/agent/sessions/compaction.rb +10 -0
- data/lib/agent/sessions/env_override.rb +9 -0
- data/lib/agent/sessions/error.rb +7 -0
- data/lib/agent/sessions/home_expansion.rb +27 -0
- data/lib/agent/sessions/location.rb +50 -0
- data/lib/agent/sessions/message.rb +36 -0
- data/lib/agent/sessions/missing_dependency.rb +7 -0
- data/lib/agent/sessions/node.rb +15 -0
- data/lib/agent/sessions/part.rb +24 -0
- data/lib/agent/sessions/readers/amp.rb +130 -0
- data/lib/agent/sessions/readers/base.rb +282 -0
- data/lib/agent/sessions/readers/claude.rb +281 -0
- data/lib/agent/sessions/readers/codex.rb +234 -0
- data/lib/agent/sessions/readers/copilot.rb +80 -0
- data/lib/agent/sessions/readers/gemini.rb +171 -0
- data/lib/agent/sessions/readers/grok.rb +155 -0
- data/lib/agent/sessions/readers/opencode.rb +224 -0
- data/lib/agent/sessions/readers/pi.rb +129 -0
- data/lib/agent/sessions/readers/qwen.rb +122 -0
- data/lib/agent/sessions/session.rb +75 -0
- data/lib/agent/sessions/sqlite.rb +55 -0
- data/lib/agent/sessions/store.rb +15 -0
- data/lib/agent/sessions/unknown_agent.rb +7 -0
- data/lib/agent/sessions/unreadable_store.rb +7 -0
- data/lib/agent/sessions/unsupported_format.rb +7 -0
- data/lib/agent/sessions/usage.rb +45 -0
- data/lib/agent/sessions/version.rb +7 -0
- data/lib/agent/sessions.rb +199 -0
- data/lib/agent_sessions.rb +1 -0
- metadata +124 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Readers
|
|
6
|
+
# Claude Code transcripts. Written against 142 real transcripts, 29,688
|
|
7
|
+
# records, inventoried 2026-08-12.
|
|
8
|
+
#
|
|
9
|
+
# The content vocabulary is a straight match for this gem's: text, thinking,
|
|
10
|
+
# tool_use, tool_result and image are exactly the five part types the design
|
|
11
|
+
# doc names, so nothing here has to invent a mapping. What Claude adds is
|
|
12
|
+
# everything *around* the conversation — a third of all records are session
|
|
13
|
+
# state, and two more kinds carry context the model saw without being a turn
|
|
14
|
+
# anyone took.
|
|
15
|
+
class Claude < Base
|
|
16
|
+
# State, not conversation, and together 11,000+ of the records written.
|
|
17
|
+
# Skipped in silence: warning about a record deliberately classified would
|
|
18
|
+
# teach a caller that warnings are noise.
|
|
19
|
+
#
|
|
20
|
+
# atis-latch and bridge-session postdate the corpus above — found by
|
|
21
|
+
# running this reader over a live 2026-08-24 transcript and reading its
|
|
22
|
+
# own warnings (23 and 17 records), the same way Codex's tool list grew.
|
|
23
|
+
# Both are session plumbing: a latch marker, and the record tying a
|
|
24
|
+
# local transcript to its cloud session id (bridgeSessionId, owner
|
|
25
|
+
# uuids). Neither is a turn anyone took.
|
|
26
|
+
NON_MESSAGE_TYPES = %w[ai-title mode permission-mode agent-name last-prompt
|
|
27
|
+
file-history-snapshot file-history-delta queue-operation
|
|
28
|
+
pr-link summary atis-latch bridge-session].freeze
|
|
29
|
+
|
|
30
|
+
# Context the model saw, but not a turn: `system` is turn_duration,
|
|
31
|
+
# stop_hook_summary, away_summary, local_command; `attachment` is hook
|
|
32
|
+
# output, skill listings, task reminders, pasted files. Same judgement
|
|
33
|
+
# Codex's event_msg gets — available on request, never on by default.
|
|
34
|
+
EVENT_TYPES = %w[system attachment].freeze
|
|
35
|
+
|
|
36
|
+
CONTENT_PARTS = { "text" => :text, "thinking" => :thinking, "tool_use" => :tool_use,
|
|
37
|
+
"tool_result" => :tool_result, "image" => :image }.freeze
|
|
38
|
+
|
|
39
|
+
# How Claude Code points at output too large to inline. It is prose, not a
|
|
40
|
+
# structured field — 24 real tool_result parts and 149 attachments carry
|
|
41
|
+
# this sentence — so the path has to be matched out of the text.
|
|
42
|
+
SPILL = /Full output saved to:\s*(\S+)/
|
|
43
|
+
|
|
44
|
+
# A spilled file is read whole. The largest observed is well under this;
|
|
45
|
+
# the cap exists because the pointer says nothing about the size.
|
|
46
|
+
MAX_SPILL_BYTES = 4_000_000
|
|
47
|
+
|
|
48
|
+
# Every uuid-bearing record names the record it followed, and 380 branch
|
|
49
|
+
# points sit across 85 of 151 real transcripts — a turn edited and re-run
|
|
50
|
+
# leaves two children under one parent. Exactly one root per file and no
|
|
51
|
+
# orphaned parent link was found in that corpus, so the links are
|
|
52
|
+
# trustworthy enough to build a tree from.
|
|
53
|
+
def branching? = true
|
|
54
|
+
|
|
55
|
+
def initialize(session, resolve_spills: true, **rest)
|
|
56
|
+
super(session, **rest)
|
|
57
|
+
@resolve_spills = resolve_spills
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The transcripts of agents this session spawned, as readers of their own.
|
|
61
|
+
# Exposed rather than inlined, per design doc 8.1: a subagent's turns are
|
|
62
|
+
# not the parent's turns, and merging them would break every count taken
|
|
63
|
+
# from this reader. 124 of these sit beside real sessions on this machine.
|
|
64
|
+
#
|
|
65
|
+
# isSidechain is false on all 22,072 records in the main transcripts, so
|
|
66
|
+
# there is nothing to filter out there — the separation is already how
|
|
67
|
+
# Claude Code writes them.
|
|
68
|
+
def subagents
|
|
69
|
+
entries = begin
|
|
70
|
+
Dir.children(File.join(sidecar_root, "subagents"))
|
|
71
|
+
rescue SystemCallError
|
|
72
|
+
return []
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
entries.sort.filter_map do |name|
|
|
76
|
+
next unless File.extname(name) == ".jsonl"
|
|
77
|
+
|
|
78
|
+
child = child_session(File.join(sidecar_root, "subagents", name))
|
|
79
|
+
child && self.class.new(child, resolve_spills: @resolve_spills, include_events: include_events)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Session totals, summed over assistant records but deduplicated by
|
|
84
|
+
# message.id first — and the dedup is most of the number. One API
|
|
85
|
+
# response streams into one record PER CONTENT BLOCK, each carrying the
|
|
86
|
+
# same message.id and the same usage: in one real transcript on this
|
|
87
|
+
# machine (2026-08-24), 260 assistant records share 124 message ids, 94
|
|
88
|
+
# of which repeat with byte-identical usage. A naive sum reports roughly
|
|
89
|
+
# double what Anthropic billed. An id-less record (not observed, but
|
|
90
|
+
# rule 2 says formats drift) is counted rather than dropped: overcounting
|
|
91
|
+
# a novelty beats silently ignoring it.
|
|
92
|
+
def usage
|
|
93
|
+
seen = {}
|
|
94
|
+
total = nil
|
|
95
|
+
each_record do |record, _line_number|
|
|
96
|
+
usage = usage_from(record)
|
|
97
|
+
next unless usage
|
|
98
|
+
|
|
99
|
+
id = record.dig("message", "id")
|
|
100
|
+
next if id && seen[id]
|
|
101
|
+
|
|
102
|
+
seen[id] = true if id
|
|
103
|
+
total = total ? total + usage : usage
|
|
104
|
+
end
|
|
105
|
+
total
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
attr_reader :resolve_spills
|
|
111
|
+
|
|
112
|
+
def node_id_for(record) = record["uuid"]
|
|
113
|
+
def parent_id_for(record) = record["parentUuid"]
|
|
114
|
+
|
|
115
|
+
def message_for(record, line_number)
|
|
116
|
+
type = record["type"]
|
|
117
|
+
return nil if NON_MESSAGE_TYPES.include?(type)
|
|
118
|
+
return event_message(record, type) if EVENT_TYPES.include?(type)
|
|
119
|
+
|
|
120
|
+
unless %w[user assistant].include?(type)
|
|
121
|
+
warn_about("line #{line_number}: unrecognized record type #{type.inspect}")
|
|
122
|
+
return build(record, :unknown, [Part.new(type: :unknown)])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
build(record, type.to_sym, content_parts(record, line_number))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# content is an Array of parts in 17,408 real messages and a bare String
|
|
129
|
+
# in 636. The String spelling is the same thing said shorter.
|
|
130
|
+
def content_parts(record, line_number)
|
|
131
|
+
content = record.dig("message", "content")
|
|
132
|
+
return [Part.new(type: :text, text: content)] if content.is_a?(String)
|
|
133
|
+
|
|
134
|
+
Array(content).map { |item| part_for(item, line_number) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def part_for(item, line_number)
|
|
138
|
+
return Part.new(type: :unknown) unless item.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
case CONTENT_PARTS[item["type"]]
|
|
141
|
+
when :text then Part.new(type: :text, text: item["text"].to_s)
|
|
142
|
+
when :thinking then Part.new(type: :thinking, text: item["thinking"].to_s)
|
|
143
|
+
when :image then Part.new(type: :image)
|
|
144
|
+
when :tool_use
|
|
145
|
+
Part.new(type: :tool_use, name: item["name"], call_id: item["id"],
|
|
146
|
+
text: stringify(item["input"]))
|
|
147
|
+
when :tool_result
|
|
148
|
+
Part.new(type: :tool_result, call_id: item["tool_use_id"],
|
|
149
|
+
text: spilled(flatten_result(item["content"])))
|
|
150
|
+
else
|
|
151
|
+
warn_about("line #{line_number}: unrecognized content part #{item["type"].inspect}")
|
|
152
|
+
Part.new(type: :unknown, text: item["text"])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A tool_result's content is a String, or an array of parts the same shape
|
|
157
|
+
# as a message's. Only its text is kept here; raw holds the rest.
|
|
158
|
+
def flatten_result(content)
|
|
159
|
+
return content if content.is_a?(String)
|
|
160
|
+
|
|
161
|
+
Array(content).filter_map { |item| item["text"] if item.is_a?(Hash) && item["type"] == "text" }.join
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def event_message(record, type)
|
|
165
|
+
return nil unless include_events
|
|
166
|
+
|
|
167
|
+
label = type == "system" ? record["subtype"] : record.dig("attachment", "type")
|
|
168
|
+
build(record, :system, [Part.new(type: :unknown, text: label)])
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def build(record, role, parts)
|
|
172
|
+
model = record.dig("message", "model")
|
|
173
|
+
Message.new(role: role, at: time_from(record["timestamp"]), parts: parts, raw: record,
|
|
174
|
+
usage: usage_from(record), model: model.is_a?(String) ? model : nil)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# message.usage sits on assistant records; user and event records dig to
|
|
178
|
+
# nil and carry none. Key names verified against a real transcript on
|
|
179
|
+
# this machine (2026-08-24): input_tokens is already DISJOINT from the
|
|
180
|
+
# two cache counts (input_tokens 2 beside cache_read_input_tokens
|
|
181
|
+
# 24,332 in the sample — Anthropic semantics, no subtraction needed),
|
|
182
|
+
# and thinking tokens hide one level down in output_tokens_details.
|
|
183
|
+
# A usage hash whose every mapped key fails count_from yields nil, not
|
|
184
|
+
# an all-nil Usage: an empty answer should look absent, not present.
|
|
185
|
+
def usage_from(record)
|
|
186
|
+
usage = record.dig("message", "usage")
|
|
187
|
+
return nil unless usage.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
mapped = Usage.new(input: count_from(usage["input_tokens"]),
|
|
190
|
+
output: count_from(usage["output_tokens"]),
|
|
191
|
+
cache_read: count_from(usage["cache_read_input_tokens"]),
|
|
192
|
+
cache_creation: count_from(usage["cache_creation_input_tokens"]),
|
|
193
|
+
reasoning: count_from(usage.dig("output_tokens_details", "thinking_tokens")))
|
|
194
|
+
mapped.to_h.each_value.any? ? mapped : nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def stringify(value)
|
|
198
|
+
value.is_a?(String) || value.nil? ? value.to_s : JSON.generate(value)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Replaces "Output too large … Full output saved to: <path>" with what the
|
|
202
|
+
# file actually holds, so a :tool_result carries content rather than a
|
|
203
|
+
# pointer (design doc 8.1).
|
|
204
|
+
#
|
|
205
|
+
# The path is read out of tool output, which is untrusted: a transcript
|
|
206
|
+
# can say anything, including that its spill lives in /etc/passwd or in
|
|
207
|
+
# another project's directory. Resolving it blindly would turn this reader
|
|
208
|
+
# into a file-read primitive driven by content. Only the session's own
|
|
209
|
+
# sidecar directory is readable, checked before the path is opened and
|
|
210
|
+
# again through realpath so a symlink inside it cannot lead out.
|
|
211
|
+
def spilled(text)
|
|
212
|
+
return text unless resolve_spills && text.is_a?(String)
|
|
213
|
+
|
|
214
|
+
path = text[SPILL, 1]
|
|
215
|
+
return text unless path
|
|
216
|
+
|
|
217
|
+
candidate = File.expand_path(path)
|
|
218
|
+
unless readable_spill?(candidate)
|
|
219
|
+
return warn_about("spill path is outside this session's sidecar tree " \
|
|
220
|
+
"and was not read: #{path}") || text
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
read_spill(candidate) || text
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The trees a spill may legitimately live in. Own sidecar always; plus the
|
|
227
|
+
# enclosing one when this transcript is itself a subagent, because a
|
|
228
|
+
# subagent has no sidecar of its own — its oversized output spills to
|
|
229
|
+
# <parent-id>/tool-results/. Running this over 124 real subagent
|
|
230
|
+
# transcripts is what found that; a boundary drawn at the subagent's own
|
|
231
|
+
# id refused every spill they reference.
|
|
232
|
+
def spill_roots
|
|
233
|
+
@spill_roots ||= begin
|
|
234
|
+
roots = [sidecar_root]
|
|
235
|
+
roots << File.dirname(File.dirname(session.path)) if subagent_transcript?
|
|
236
|
+
roots
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def subagent_transcript? = File.basename(File.dirname(session.path)) == "subagents"
|
|
241
|
+
|
|
242
|
+
def readable_spill?(candidate)
|
|
243
|
+
root = spill_roots.find { |dir| candidate.start_with?("#{dir}/") }
|
|
244
|
+
return false unless root
|
|
245
|
+
|
|
246
|
+
# Again through realpath, so a symlink planted inside the tree cannot
|
|
247
|
+
# lead out of it. A path that will not resolve is left to the read
|
|
248
|
+
# below to report, rather than being called a security problem.
|
|
249
|
+
File.realpath(candidate).start_with?("#{File.realpath(root)}/")
|
|
250
|
+
rescue SystemCallError
|
|
251
|
+
true
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def read_spill(path)
|
|
255
|
+
return warn_about("spill file is larger than #{MAX_SPILL_BYTES} bytes; " \
|
|
256
|
+
"left as a pointer: #{path}") if File.size(path) > MAX_SPILL_BYTES
|
|
257
|
+
|
|
258
|
+
File.read(path)
|
|
259
|
+
rescue SystemCallError
|
|
260
|
+
warn_about("spill file could not be read; left as a pointer: #{path}")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The directory Claude Code names after the session id, holding
|
|
264
|
+
# subagents/ and tool-results/. Base#bytes_for already counts what is in
|
|
265
|
+
# it; this is the same convention read rather than measured.
|
|
266
|
+
def sidecar_root
|
|
267
|
+
@sidecar_root ||= session.path.delete_suffix(File.extname(session.path))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def child_session(path)
|
|
271
|
+
stat = File.stat(path)
|
|
272
|
+
Session.new(agent: session.agent, id: File.basename(path, ".jsonl"), path: path,
|
|
273
|
+
started_at: nil, updated_at: stat.mtime, bytes: stat.size,
|
|
274
|
+
format: session.format, fidelity: session.fidelity)
|
|
275
|
+
rescue SystemCallError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Readers
|
|
6
|
+
# Codex rollout files. Every mapping here was written against a real corpus
|
|
7
|
+
# rather than from the format notes: 415 files, 128,987 records, inventoried
|
|
8
|
+
# 2026-08-12. The distribution is the reason for several decisions below —
|
|
9
|
+
# response_item 57%, event_msg 38%, turn_context 4%, session_meta 416,
|
|
10
|
+
# world_state 182, inter_agent_communication_metadata 129, compacted 18.
|
|
11
|
+
#
|
|
12
|
+
# Codex was chosen as the first reader for exactly this reason. pi was the
|
|
13
|
+
# planned reference implementation, but its store held no session files at
|
|
14
|
+
# all on the machine available, so every claim about its content would have
|
|
15
|
+
# been inference. A reference implementation has to be falsifiable.
|
|
16
|
+
class Codex < Base
|
|
17
|
+
# Known, and deliberately not messages: the session header, per-turn
|
|
18
|
+
# configuration, and two state records Codex added in July 2026. Silence
|
|
19
|
+
# here is a judgement, not an oversight — these are not conversation, and
|
|
20
|
+
# warning about them would train a caller to ignore warnings.
|
|
21
|
+
NON_MESSAGE_TYPES = %w[session_meta turn_context world_state
|
|
22
|
+
inter_agent_communication_metadata].freeze
|
|
23
|
+
|
|
24
|
+
# "developer" is what Codex writes where the normalized vocabulary says
|
|
25
|
+
# :system. It is 101 of 292 role-bearing records in the sample, so this is
|
|
26
|
+
# the common path, not an edge case.
|
|
27
|
+
ROLES = { "user" => :user, "assistant" => :assistant, "developer" => :system,
|
|
28
|
+
"system" => :system, "tool" => :tool }.freeze
|
|
29
|
+
|
|
30
|
+
# encrypted_content maps to :unknown deliberately, not for want of a
|
|
31
|
+
# better bucket: 80 real content items are encrypted by the model and this
|
|
32
|
+
# gem will never read them. Recognized-and-unreadable is a different thing
|
|
33
|
+
# from unrecognized, and only the second deserves a warning — a warning
|
|
34
|
+
# that fires on a permanent, understood condition is noise on every read.
|
|
35
|
+
CONTENT_PARTS = { "input_text" => :text, "output_text" => :text, "text" => :text,
|
|
36
|
+
"summary_text" => :text, "input_image" => :image,
|
|
37
|
+
"output_image" => :image, "encrypted_content" => :unknown }.freeze
|
|
38
|
+
|
|
39
|
+
# Every entry past the first three in each list came from running this
|
|
40
|
+
# reader over all 415 files and reading its own warnings: a 25-file sample
|
|
41
|
+
# showed none of them. Counts in that corpus: web_search_call 288,
|
|
42
|
+
# ghost_snapshot 197, agent_message 129, tool_search_call and
|
|
43
|
+
# tool_search_output 26 each, image_generation_call 1.
|
|
44
|
+
TOOL_CALLS = %w[custom_tool_call function_call local_shell_call
|
|
45
|
+
web_search_call tool_search_call].freeze
|
|
46
|
+
TOOL_OUTPUTS = %w[custom_tool_call_output function_call_output local_shell_call_output
|
|
47
|
+
tool_search_output].freeze
|
|
48
|
+
|
|
49
|
+
# Internal state that happens to travel as a response_item. Skipped in
|
|
50
|
+
# silence for the same reason turn_context is: it is not conversation, and
|
|
51
|
+
# a warning a caller must learn to ignore is worse than no warning.
|
|
52
|
+
NON_MESSAGE_ITEMS = %w[ghost_snapshot].freeze
|
|
53
|
+
|
|
54
|
+
# Where a tool call keeps what it was called with. custom_tool_call uses
|
|
55
|
+
# input, function_call uses arguments, web_search_call uses action, and
|
|
56
|
+
# tool_search_call uses arguments as a Hash rather than a String.
|
|
57
|
+
CALL_INPUTS = %w[input arguments action].freeze
|
|
58
|
+
|
|
59
|
+
# And where an output keeps its result.
|
|
60
|
+
CALL_OUTPUTS = %w[output tools].freeze
|
|
61
|
+
|
|
62
|
+
# Session totals. Codex writes no usage on its messages; it writes
|
|
63
|
+
# token_count event records whose info.total_token_usage is a RUNNING
|
|
64
|
+
# TOTAL — verified against a real rollout on this machine (2026-08-24):
|
|
65
|
+
# consecutive records report total 33,751 then 69,135 while their
|
|
66
|
+
# last_token_usage differ, so the last record is the session and summing
|
|
67
|
+
# would multiply-count every earlier turn.
|
|
68
|
+
#
|
|
69
|
+
# Two normalizations, both from that same file:
|
|
70
|
+
#
|
|
71
|
+
# input_tokens INCLUDES cached_input_tokens (33,431 including 19,200
|
|
72
|
+
# in the sample) — the opposite of Claude's disjoint spelling — so the
|
|
73
|
+
# cached share is subtracted to make Usage#input mean one thing across
|
|
74
|
+
# agents. Clamped at zero: a count that went negative would mean the
|
|
75
|
+
# two fields disagree, and a wrong zero beats a negative token count.
|
|
76
|
+
#
|
|
77
|
+
# cache_write_input_tokens maps to cache_creation. total_tokens is
|
|
78
|
+
# deliberately not mapped anywhere: it restates the other fields, and
|
|
79
|
+
# any bucket it landed in would be double-counted by a caller summing
|
|
80
|
+
# buckets.
|
|
81
|
+
def usage
|
|
82
|
+
info = nil
|
|
83
|
+
each_record do |record, _line_number|
|
|
84
|
+
next unless record["type"] == "event_msg"
|
|
85
|
+
|
|
86
|
+
candidate = record.dig("payload", "info", "total_token_usage")
|
|
87
|
+
info = candidate if record.dig("payload", "type") == "token_count" && candidate.is_a?(Hash)
|
|
88
|
+
end
|
|
89
|
+
return nil unless info
|
|
90
|
+
|
|
91
|
+
input = count_from(info["input_tokens"])
|
|
92
|
+
cached = count_from(info["cached_input_tokens"])
|
|
93
|
+
mapped = Usage.new(input: input && cached ? [input - cached, 0].max : input,
|
|
94
|
+
output: count_from(info["output_tokens"]),
|
|
95
|
+
cache_read: cached,
|
|
96
|
+
cache_creation: count_from(info["cache_write_input_tokens"]),
|
|
97
|
+
reasoning: count_from(info["reasoning_output_tokens"]))
|
|
98
|
+
# Same rule as Claude's usage_from: a token_count record whose every
|
|
99
|
+
# field failed the count check answers nil, not an all-nil Usage.
|
|
100
|
+
mapped.to_h.each_value.any? ? mapped : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def message_for(record, line_number)
|
|
106
|
+
type = record["type"]
|
|
107
|
+
return nil if NON_MESSAGE_TYPES.include?(type) || type == "compacted"
|
|
108
|
+
return event_message(record) if type == "event_msg"
|
|
109
|
+
|
|
110
|
+
payload = record["payload"]
|
|
111
|
+
unless type == "response_item" && payload.is_a?(Hash)
|
|
112
|
+
return warn_about("line #{line_number}: unrecognized record type #{type.inspect}") ||
|
|
113
|
+
unknown_message(record)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
item_message(record, payload, line_number)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def item_message(record, payload, line_number)
|
|
120
|
+
case payload["type"]
|
|
121
|
+
when "message" then text_message(record, payload, line_number)
|
|
122
|
+
# Multi-agent traffic: author and recipient instead of role, but the
|
|
123
|
+
# content array is a normal one. Conversation, so it is read as such.
|
|
124
|
+
when "agent_message" then build(record, :assistant, content_parts(payload, line_number))
|
|
125
|
+
when "reasoning" then reasoning_message(record, payload)
|
|
126
|
+
when *TOOL_CALLS then tool_call_message(record, payload)
|
|
127
|
+
when *TOOL_OUTPUTS then tool_output_message(record, payload)
|
|
128
|
+
when *NON_MESSAGE_ITEMS then nil
|
|
129
|
+
# The result is base64 and was 2.5 MB in the one real occurrence. It
|
|
130
|
+
# stays in raw: inlining it would make holding the message cost
|
|
131
|
+
# megabytes, and decoding pixels is not this layer's job.
|
|
132
|
+
when "image_generation_call" then build(record, :assistant, [Part.new(type: :image)])
|
|
133
|
+
else
|
|
134
|
+
warn_about("line #{line_number}: unrecognized response_item type #{payload["type"].inspect}")
|
|
135
|
+
unknown_message(record)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def text_message(record, payload, line_number)
|
|
140
|
+
build(record, role_for(payload["role"], line_number), content_parts(payload, line_number))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def content_parts(payload, line_number)
|
|
144
|
+
Array(payload["content"]).map do |item|
|
|
145
|
+
next Part.new(type: :unknown) unless item.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
kind = CONTENT_PARTS[item["type"]]
|
|
148
|
+
next Part.new(type: kind, text: %i[image unknown].include?(kind) ? nil : item["text"].to_s) if kind
|
|
149
|
+
|
|
150
|
+
warn_about("line #{line_number}: unrecognized content part #{item["type"].inspect}")
|
|
151
|
+
Part.new(type: :unknown, text: item["text"])
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# content is null on every reasoning record observed; the readable text is
|
|
156
|
+
# in summary. encrypted_content holds the rest and this gem cannot decrypt
|
|
157
|
+
# it, so exposing a :thinking part built from summary alone is the honest
|
|
158
|
+
# maximum — claiming more would misreport fidelity.
|
|
159
|
+
def reasoning_message(record, payload)
|
|
160
|
+
parts = Array(payload["summary"]).filter_map do |item|
|
|
161
|
+
Part.new(type: :thinking, text: item["text"].to_s) if item.is_a?(Hash)
|
|
162
|
+
end
|
|
163
|
+
build(record, :assistant, parts)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The call is the assistant's act; the output is the tool answering.
|
|
167
|
+
#
|
|
168
|
+
# Not every call carries a name: web_search_call and tool_search_call
|
|
169
|
+
# identify themselves only by record type, so the type minus its "_call"
|
|
170
|
+
# suffix is the name. That is a derivation, not a guess — it produces
|
|
171
|
+
# exactly the name the agent would use ("web_search", "tool_search").
|
|
172
|
+
def tool_call_message(record, payload)
|
|
173
|
+
name = payload["name"] || payload["type"].to_s.sub(/_call\z/, "")
|
|
174
|
+
part = Part.new(type: :tool_use, name: name, call_id: payload["call_id"],
|
|
175
|
+
text: stringify(payload, CALL_INPUTS))
|
|
176
|
+
build(record, :assistant, [part])
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def tool_output_message(record, payload)
|
|
180
|
+
part = Part.new(type: :tool_result, call_id: payload["call_id"],
|
|
181
|
+
text: stringify(payload, CALL_OUTPUTS))
|
|
182
|
+
build(record, :tool, [part])
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A call's input is a String for some record types and a Hash for others
|
|
186
|
+
# (tool_search_call's arguments, web_search_call's action). Serializing the
|
|
187
|
+
# Hash keeps the value readable and lossless rather than rendering it as
|
|
188
|
+
# Ruby's inspect output; raw still holds the original either way.
|
|
189
|
+
def stringify(payload, keys)
|
|
190
|
+
key = keys.find { |candidate| payload.key?(candidate) }
|
|
191
|
+
value = key && payload[key]
|
|
192
|
+
value.is_a?(String) || value.nil? ? value.to_s : JSON.generate(value)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Opt-in only: event_msg is 38% of the corpus and is UI bookkeeping (token
|
|
196
|
+
# counts, rate limits, task_started). Including it by default would inflate
|
|
197
|
+
# every message count a caller made. No warning — the caller asked.
|
|
198
|
+
def event_message(record)
|
|
199
|
+
return nil unless include_events
|
|
200
|
+
|
|
201
|
+
build(record, :system, [Part.new(type: :unknown, text: record.dig("payload", "type"))])
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def unknown_message(record)
|
|
205
|
+
build(record, :unknown, [Part.new(type: :unknown)])
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def build(record, role, parts)
|
|
209
|
+
Message.new(role: role, at: time_from(record["timestamp"]), parts: parts, raw: record)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def role_for(role, line_number)
|
|
213
|
+
ROLES.fetch(role) do
|
|
214
|
+
warn_about("line #{line_number}: unrecognized role #{role.inspect}")
|
|
215
|
+
:unknown
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# replacement_history is a restatement of turns already yielded, so it is
|
|
220
|
+
# counted, never replayed. A reader that expanded it would report the same
|
|
221
|
+
# conversation twice — the miscount the design doc warns about, arriving
|
|
222
|
+
# through the reader instead of through a naive line count.
|
|
223
|
+
def compaction_for(record)
|
|
224
|
+
return nil unless record["type"] == "compacted"
|
|
225
|
+
|
|
226
|
+
history = record.dig("payload", "replacement_history")
|
|
227
|
+
Compaction.new(at: time_from(record["timestamp"]),
|
|
228
|
+
replaced_count: history.is_a?(Array) ? history.size : 0,
|
|
229
|
+
raw: record)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Readers
|
|
6
|
+
# GitHub Copilot CLI turns, from the SQLite store the adapter enumerates.
|
|
7
|
+
#
|
|
8
|
+
# The SCHEMA is verified (schema_version 3 on this machine, 2026-08-24):
|
|
9
|
+
# turns holds id, session_id, turn_index, user_message, assistant_response,
|
|
10
|
+
# timestamp. The CONTENT is not — the one real session here has zero turn
|
|
11
|
+
# rows, so no turn has ever been read. The column names are unambiguous
|
|
12
|
+
# enough to map without guessing at structure, which is why this reader
|
|
13
|
+
# exists at all rather than waiting; what it cannot promise is that a real
|
|
14
|
+
# turn holds plain text in those columns rather than, say, JSON.
|
|
15
|
+
#
|
|
16
|
+
# fidelity is :messages, not :full — one row is a whole exchange, so the
|
|
17
|
+
# tool calls and reasoning that happened inside it are not recoverable
|
|
18
|
+
# from this table. What a caller gets is what was said, not how.
|
|
19
|
+
class Copilot < Base
|
|
20
|
+
# One row is a user turn AND the assistant's reply, so each row yields
|
|
21
|
+
# two messages. They share a raw record: rule 1 keeps the row intact,
|
|
22
|
+
# and splitting it into two half-rows would misreport what was stored.
|
|
23
|
+
def each_message
|
|
24
|
+
return enum_for(:each_message) unless block_given?
|
|
25
|
+
|
|
26
|
+
each_record do |record, _index|
|
|
27
|
+
user = record["user_message"]
|
|
28
|
+
yield build(record, :user, user) if usable?(user)
|
|
29
|
+
reply = record["assistant_response"]
|
|
30
|
+
yield build(record, :assistant, reply) if usable?(reply)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# No token or cost column exists anywhere in this schema — not on
|
|
35
|
+
# sessions, not on turns. nil is the format speaking, and must not be
|
|
36
|
+
# mistaken for a session that cost nothing.
|
|
37
|
+
def usage = nil
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def usable?(value) = value.is_a?(String) && !value.empty?
|
|
42
|
+
|
|
43
|
+
def build(record, role, text)
|
|
44
|
+
Message.new(role: role, at: time_from(record["timestamp"]), parts: [Part.new(type: :text, text: text)],
|
|
45
|
+
raw: record, usage: nil, model: nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Rows ordered by turn_index, the column that exists precisely to say
|
|
49
|
+
# what order they happened in; id is the tiebreak so a store with
|
|
50
|
+
# repeated indices still reads deterministically.
|
|
51
|
+
def each_record
|
|
52
|
+
require_sqlite!
|
|
53
|
+
db = nil
|
|
54
|
+
index = 0
|
|
55
|
+
begin
|
|
56
|
+
db = Sqlite.open_readonly(session.path)
|
|
57
|
+
db.execute("SELECT user_message, assistant_response, timestamp, turn_index " \
|
|
58
|
+
"FROM turns WHERE session_id = ? ORDER BY turn_index, id", [session.id]) do |row|
|
|
59
|
+
index += 1
|
|
60
|
+
user, reply, timestamp, turn_index = row
|
|
61
|
+
yield({ "user_message" => user, "assistant_response" => reply,
|
|
62
|
+
"timestamp" => timestamp, "turn_index" => turn_index }, index)
|
|
63
|
+
end
|
|
64
|
+
rescue SQLite3::Exception => e
|
|
65
|
+
warn_about("#{session.path} could not be read (#{e.class.name.split("::").last})")
|
|
66
|
+
ensure
|
|
67
|
+
db&.close
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def require_sqlite!
|
|
72
|
+
require "sqlite3"
|
|
73
|
+
rescue LoadError
|
|
74
|
+
raise MissingDependency,
|
|
75
|
+
"Copilot CLI turns live in session-store.db (SQLite); add the sqlite3 gem to read them"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|