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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +98 -0
  5. data/exe/agent-sessions +8 -0
  6. data/lib/agent/sessions/adapters/amp.rb +162 -0
  7. data/lib/agent/sessions/adapters/base.rb +259 -0
  8. data/lib/agent/sessions/adapters/claude.rb +123 -0
  9. data/lib/agent/sessions/adapters/codex.rb +121 -0
  10. data/lib/agent/sessions/adapters/copilot.rb +128 -0
  11. data/lib/agent/sessions/adapters/cursor.rb +176 -0
  12. data/lib/agent/sessions/adapters/cursor_ide.rb +136 -0
  13. data/lib/agent/sessions/adapters/enumeration.rb +252 -0
  14. data/lib/agent/sessions/adapters/gemini.rb +133 -0
  15. data/lib/agent/sessions/adapters/grok.rb +122 -0
  16. data/lib/agent/sessions/adapters/opencode.rb +322 -0
  17. data/lib/agent/sessions/adapters/pi.rb +185 -0
  18. data/lib/agent/sessions/adapters/qwen.rb +52 -0
  19. data/lib/agent/sessions/audit.rb +71 -0
  20. data/lib/agent/sessions/check.rb +9 -0
  21. data/lib/agent/sessions/cli.rb +532 -0
  22. data/lib/agent/sessions/compaction.rb +10 -0
  23. data/lib/agent/sessions/env_override.rb +9 -0
  24. data/lib/agent/sessions/error.rb +7 -0
  25. data/lib/agent/sessions/home_expansion.rb +27 -0
  26. data/lib/agent/sessions/location.rb +50 -0
  27. data/lib/agent/sessions/message.rb +36 -0
  28. data/lib/agent/sessions/missing_dependency.rb +7 -0
  29. data/lib/agent/sessions/node.rb +15 -0
  30. data/lib/agent/sessions/part.rb +24 -0
  31. data/lib/agent/sessions/readers/amp.rb +130 -0
  32. data/lib/agent/sessions/readers/base.rb +282 -0
  33. data/lib/agent/sessions/readers/claude.rb +281 -0
  34. data/lib/agent/sessions/readers/codex.rb +234 -0
  35. data/lib/agent/sessions/readers/copilot.rb +80 -0
  36. data/lib/agent/sessions/readers/gemini.rb +171 -0
  37. data/lib/agent/sessions/readers/grok.rb +155 -0
  38. data/lib/agent/sessions/readers/opencode.rb +224 -0
  39. data/lib/agent/sessions/readers/pi.rb +129 -0
  40. data/lib/agent/sessions/readers/qwen.rb +122 -0
  41. data/lib/agent/sessions/session.rb +75 -0
  42. data/lib/agent/sessions/sqlite.rb +55 -0
  43. data/lib/agent/sessions/store.rb +15 -0
  44. data/lib/agent/sessions/unknown_agent.rb +7 -0
  45. data/lib/agent/sessions/unreadable_store.rb +7 -0
  46. data/lib/agent/sessions/unsupported_format.rb +7 -0
  47. data/lib/agent/sessions/usage.rb +45 -0
  48. data/lib/agent/sessions/version.rb +7 -0
  49. data/lib/agent/sessions.rb +199 -0
  50. data/lib/agent_sessions.rb +1 -0
  51. 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