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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ class MissingDependency < Error; end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # One message in a branching conversation, with the messages that follow it.
6
+ # Agents that let a turn be edited and re-run record two children under one
7
+ # parent: 380 such branch points sit across 85 of 151 real Claude transcripts,
8
+ # so a caller reading `messages` in file order is reading two alternative
9
+ # histories interleaved without being told.
10
+ #
11
+ # children is a plain Array and the Node is frozen, so the shape is settled
12
+ # before anyone sees it.
13
+ Node = Data.define(:message, :children)
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # One piece of a message. `type` is the normalized vocabulary (design doc §5);
6
+ # everything an agent said that this gem could not classify arrives as
7
+ # :unknown rather than as an exception, and the message's `raw` still holds it.
8
+ #
9
+ # text carries the readable content for :text, :thinking and :tool_result.
10
+ # name and call_id are tool plumbing, nil elsewhere. An :image part has
11
+ # neither — its URL or payload stays in raw, because normalizing an image
12
+ # would mean deciding whether to load it, and reading is stat-cheap by design.
13
+ Part = Data.define(:type, :text, :name, :call_id) do
14
+ self::TYPES = %i[text thinking tool_use tool_result image unknown].freeze
15
+
16
+ def initialize(type:, text: nil, name: nil, call_id: nil)
17
+ types = self.class::TYPES
18
+ raise ArgumentError, "part type #{type.inspect} must be one of #{types.join(", ")}" unless types.include?(type)
19
+
20
+ super
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Readers
6
+ # Amp threads. Written against the one real thread available (2026-08-14):
7
+ # 24 messages, content parts tool_use 14, tool_result 14, text 2, thinking 1.
8
+ # One thread is thin evidence beside Codex's 415 files, and this reader says
9
+ # so through partial? rather than pretending otherwise.
10
+ #
11
+ # Two things make Amp unlike the JSONL readers:
12
+ #
13
+ # A thread is ONE JSON document, so it cannot be streamed a record at a
14
+ # time. Reading any of it means holding all of it, which is the gem's one
15
+ # unbounded read (0.2 follow-up 8). each_record below is where that bound
16
+ # finally lives.
17
+ #
18
+ # And its tool results are spelled its own way — toolUseID rather than
19
+ # tool_use_id, the payload under run.result rather than content. A mapper
20
+ # copied from Claude's would produce empty tool results and no warning,
21
+ # which is why each reader maps its own agent rather than sharing one.
22
+ class Amp < Base
23
+ # 150x the observed thread. A cap has to exist because nothing about a
24
+ # thread file announces its size before it is opened, and JSON.parse of a
25
+ # 200 MB document costs several times that in live objects. Refused and
26
+ # reported beats NoMemoryError, and beats silence either way.
27
+ MAX_DOCUMENT_BYTES = 32_000_000
28
+
29
+ CONTENT_PARTS = { "text" => :text, "thinking" => :thinking,
30
+ "tool_use" => :tool_use, "tool_result" => :tool_result,
31
+ "image" => :image }.freeze
32
+
33
+ ROLES = { "user" => :user, "assistant" => :assistant,
34
+ "system" => :system, "tool" => :tool }.freeze
35
+
36
+ # The server holds the canonical copy; a local thread may be a mirror of
37
+ # part of the conversation. The adapter carries the same warning.
38
+ def partial? = true
39
+
40
+ private
41
+
42
+ # Overrides the line-oriented reader wholesale: there are no lines here.
43
+ # Yields each message of the document with its index, so everything above
44
+ # this method works unchanged.
45
+ def each_record
46
+ size = File.size(session.path)
47
+ if size > MAX_DOCUMENT_BYTES
48
+ return warn_about("thread document is too large to read " \
49
+ "(#{size} bytes, over #{MAX_DOCUMENT_BYTES}); skipped")
50
+ end
51
+
52
+ document = JSON.parse(File.read(session.path))
53
+ messages = document.is_a?(Hash) ? document["messages"] : nil
54
+ return warn_about("thread document has no messages array; nothing to read") unless messages.is_a?(Array)
55
+
56
+ messages.each_with_index { |record, index| yield record, index + 1 if record.is_a?(Hash) }
57
+ rescue JSON::ParserError, EncodingError
58
+ warn_about("thread document is not valid JSON; nothing to read")
59
+ rescue SystemCallError => e
60
+ warn_about("#{session.path} could not be read (#{e.class.name.split("::").last})")
61
+ end
62
+
63
+ def message_for(record, index)
64
+ parts = Array(record["content"]).map { |item| part_for(item, index) }
65
+ Message.new(role: role_for(record["role"], index), at: sent_at(record),
66
+ parts: parts, raw: record)
67
+ end
68
+
69
+ def part_for(item, index)
70
+ return Part.new(type: :unknown) unless item.is_a?(Hash)
71
+
72
+ case CONTENT_PARTS[item["type"]]
73
+ when :text then Part.new(type: :text, text: item["text"].to_s)
74
+ when :thinking then Part.new(type: :thinking, text: item["thinking"].to_s)
75
+ when :image then Part.new(type: :image)
76
+ when :tool_use
77
+ Part.new(type: :tool_use, name: item["name"], call_id: item["id"],
78
+ text: stringify(item["input"]))
79
+ when :tool_result
80
+ Part.new(type: :tool_result, call_id: item["toolUseID"], text: tool_result_text(item))
81
+ else
82
+ warn_about("message #{index}: unrecognized content part #{item["type"].inspect}")
83
+ Part.new(type: :unknown, text: item["text"])
84
+ end
85
+ end
86
+
87
+ # A tool call that failed carries run.error and no run.result — 2 of the
88
+ # 14 tool results in the one real thread. Reading only run.result rendered
89
+ # those as empty text, which reads as "the tool returned nothing" rather
90
+ # than "the tool failed, and here is why". The status stays in raw.
91
+ def tool_result_text(item)
92
+ run = item["run"]
93
+ return "" unless run.is_a?(Hash)
94
+ return stringify(run["result"]) if run.key?("result")
95
+
96
+ error = run["error"]
97
+ return "" unless error
98
+ return error["message"] if error.is_a?(Hash) && error["message"].is_a?(String)
99
+
100
+ stringify(error)
101
+ end
102
+
103
+ def role_for(role, index)
104
+ ROLES.fetch(role) do
105
+ warn_about("message #{index}: unrecognized role #{role.inspect}")
106
+ :unknown
107
+ end
108
+ end
109
+
110
+ # Epoch milliseconds, not seconds and not a string. nil where the thread
111
+ # records no time, rather than a guess derived from the file.
112
+ #
113
+ # Split into whole seconds and a millisecond remainder rather than divided
114
+ # by 1000.0: a Float cannot hold .503 exactly, so the divided form built a
115
+ # Time whose subsecond was 2109735/4194304 and compared unequal to the
116
+ # instant it was meant to be.
117
+ def sent_at(record)
118
+ millis = record.dig("meta", "sentAt")
119
+ return nil unless millis.is_a?(Integer)
120
+
121
+ Time.at(millis / 1000, millis % 1000, :millisecond).utc
122
+ end
123
+
124
+ def stringify(value)
125
+ value.is_a?(String) || value.nil? ? value.to_s : JSON.generate(value)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Readers
6
+ # Layer 3: turning one session file into messages. Subclasses supply the
7
+ # mapping; everything about *how the file is read* lives here, because the
8
+ # three rules that make this layer survivable (design doc §5) are properties
9
+ # of the reading, not of any one agent's format:
10
+ #
11
+ # 1. raw is never dropped.
12
+ # 2. Unknown records become :unknown parts and warnings, never exceptions.
13
+ # 3. Reading streams. No code path may assume a file fits in memory.
14
+ #
15
+ # Rule 3 is why this does not use File.foreach without a chunk size. A
16
+ # truncated log can hold no newline at all, and "read one line" would then
17
+ # mean "read 2.6 GB into a String" — the file the article that started this
18
+ # gem found on a real machine.
19
+ class Base
20
+ # Chunk size, and so the largest record that can be read whole. Measured
21
+ # against 415 real Codex rollout files (128,987 records, 2026-08-12): 14
22
+ # records exceed 1 MB and the largest is 2.41 MB, so Layer 2's
23
+ # MAX_LINE_BYTES of 1 MB would silently drop real messages. 8 MB is ~3.3x
24
+ # the observed maximum. A record beyond it is reported, never dropped in
25
+ # silence, because a missing message is this gem's worst failure mode.
26
+ MAX_RECORD_BYTES = 8_000_000
27
+
28
+ attr_reader :session
29
+
30
+ def initialize(session, include_events: false)
31
+ @session = session
32
+ @include_events = include_events
33
+ @warnings = []
34
+ end
35
+
36
+ def fidelity = session.fidelity
37
+
38
+ # True where the local file is not the whole story — Amp, whose server
39
+ # holds the canonical copy. Overridden there, false everywhere else.
40
+ def partial? = false
41
+
42
+ # Populated as records are read, so this answers for whatever has been
43
+ # consumed so far. uniq because a second pass over the same file would
44
+ # otherwise repeat every warning it already reported.
45
+ def warnings = @warnings.uniq
46
+
47
+ # Streams. Yields each message as it is parsed; a caller that breaks after
48
+ # one has read one record, not the file.
49
+ def each_message
50
+ return enum_for(:each_message) unless block_given?
51
+
52
+ each_record do |record, line_number|
53
+ message = message_for(record, line_number)
54
+ yield message if message
55
+ end
56
+ end
57
+
58
+ # Eager, for sessions small enough to hold. The design doc offers both and
59
+ # names this the convenience: `messages` is what a script wants, and
60
+ # `each_message` is what a 2.6 GB file requires.
61
+ def messages = each_message.to_a
62
+
63
+ # Whether this agent records which turn each turn followed. False here:
64
+ # most stores are an append-only list and a tree would have to be invented.
65
+ def branching? = false
66
+
67
+ # The conversation as roots and their continuations, for an agent that
68
+ # records parent links. Unlike every other method here this cannot stream
69
+ # — a tree is not knowable until the last record is read — so it holds one
70
+ # session's messages at once and says so rather than pretending otherwise.
71
+ #
72
+ # Raises rather than returning an empty list or nil for a store with no
73
+ # parent links, for the reason Agent::Sessions.read raises: "this format
74
+ # does not record that" must never read as "this session has none".
75
+ def tree
76
+ unless branching?
77
+ raise UnsupportedFormat,
78
+ "#{session.agent} does not record parent links; its messages are a flat list"
79
+ end
80
+
81
+ build_tree
82
+ end
83
+
84
+ # This session's token totals as a Usage, or nil where the format does
85
+ # not record them (Amp) or this reader has not learned where they live.
86
+ # nil, not an empty Usage: "this store does not say" must never read as
87
+ # "this session cost nothing" — the same rule tree() enforces by raising.
88
+ #
89
+ # Each reader that overrides this also decides its own summation rule,
90
+ # because that rule is format knowledge: Claude repeats one API
91
+ # response's usage across several records (94 of 124 message ids in one
92
+ # real transcript), Codex writes a running total where only the last
93
+ # record counts. A base-class sum would get both wrong.
94
+ def usage = nil
95
+
96
+ # Boundaries where the agent replaced earlier turns with a summary. Its
97
+ # own pass: a caller asking only for compactions should not have to
98
+ # materialize every message to get them.
99
+ def compactions
100
+ found = []
101
+ each_record { |record, _line| (boundary = compaction_for(record)) && found << boundary }
102
+ found
103
+ end
104
+
105
+ private
106
+
107
+ attr_reader :include_events
108
+
109
+ # nil means "this record is not a message" — a header, a turn context, a
110
+ # compaction boundary. Subclasses override.
111
+ def message_for(_record, _line_number) = nil
112
+
113
+ # This record's own id and the id of the record it followed. nil from
114
+ # either means the record takes no part in the tree. A branching reader
115
+ # overrides both; the tree algorithm itself stays here, so an agent only
116
+ # has to say where its links live, never how to assemble them.
117
+ def node_id_for(_record) = nil
118
+ def parent_id_for(_record) = nil
119
+
120
+ # Two passes over one session. The first records every uuid-bearing
121
+ # record's parent and which of them became messages; the second links
122
+ # each message to the nearest ANCESTOR that is also a message.
123
+ #
124
+ # That second part is the whole difficulty. Records that are not turns sit
125
+ # in the same parent chain — 5,006 of 25,633 in the real Claude corpus are
126
+ # attachments and system records — so a message's recorded parent is
127
+ # frequently not a message. Walking up until one is found keeps the tree
128
+ # holding exactly the messages `messages` reports, no more and no fewer,
129
+ # and makes include_events change what is in the tree without changing
130
+ # whether it is well formed.
131
+ def build_tree
132
+ order = []
133
+ parents = {}
134
+ messages = {}
135
+
136
+ each_record do |record, line_number|
137
+ id = node_id_for(record)
138
+ next unless id
139
+
140
+ order << id
141
+ parents[id] = parent_id_for(record)
142
+ message = message_for(record, line_number)
143
+ messages[id] = message if message
144
+ end
145
+
146
+ link_tree(order, parents, messages)
147
+ end
148
+
149
+ def link_tree(order, parents, messages)
150
+ children = Hash.new { |hash, key| hash[key] = [] }
151
+ roots = []
152
+
153
+ order.each do |id|
154
+ next unless messages.key?(id)
155
+
156
+ ancestor = nearest_message_ancestor(parents, messages, id)
157
+ ancestor ? children[ancestor] << id : roots << id
158
+ end
159
+
160
+ # Built in reverse file order so a parent is always assembled after the
161
+ # children it needs, without recursion — a linear session of several
162
+ # thousand turns would otherwise be several thousand stack frames deep.
163
+ built = {}
164
+ order.reverse_each do |id|
165
+ next unless messages.key?(id)
166
+
167
+ built[id] = Node.new(message: messages[id], children: children[id].map { |child| built[child] }.compact)
168
+ end
169
+
170
+ roots.map { |id| built[id] }.compact
171
+ end
172
+
173
+ # Walks up the recorded chain until it reaches a record that became a
174
+ # message, or runs out. A cycle would spin here, so ids already visited
175
+ # end the walk: nothing in the real corpus contains one, and a malformed
176
+ # file must not hang a reader.
177
+ def nearest_message_ancestor(parents, messages, id)
178
+ seen = { id => true }
179
+ current = parents[id]
180
+ while current && !messages.key?(current)
181
+ break if seen[current]
182
+
183
+ seen[current] = true
184
+ current = parents[current]
185
+ end
186
+ current && messages.key?(current) ? current : nil
187
+ end
188
+
189
+ # nil means "not a compaction". Subclasses that have them override.
190
+ def compaction_for(_record) = nil
191
+
192
+ def warn_about(message)
193
+ @warnings << message
194
+ nil
195
+ end
196
+
197
+ # Yields one parsed record per complete line, with its 1-based line
198
+ # number. Three things can go wrong and none of them may raise:
199
+ #
200
+ # the file is unreadable -> one warning, no records
201
+ # a line is not JSON -> one warning naming the line, skipped
202
+ # a record exceeds the cap -> one warning naming the line, skipped
203
+ #
204
+ # The oversized case is detected structurally rather than by measuring:
205
+ # File.foreach with a chunk size hands back a chunk that does NOT end in a
206
+ # newline when the record is longer than the cap, and the following chunks
207
+ # are its continuation. A chunk shorter than the cap without a newline is
208
+ # simply the last line of a file that does not end in one.
209
+ # The file this reader streams. session.path for every agent that keeps
210
+ # its conversation in the file Layer 2 enumerated — which is all of them
211
+ # but Grok, whose session is a DIRECTORY: Layer 2 points at its
212
+ # summary.json while the turns are in chat_history.jsonl beside it.
213
+ # A hook here rather than an each_record override there, because the
214
+ # rest of the streaming (the chunk cap, the oversized report, the
215
+ # per-line warnings) is exactly what such a reader still wants.
216
+ def record_path = session.path
217
+
218
+ def each_record
219
+ line_number = 0
220
+ oversized_at = nil
221
+
222
+ File.foreach(record_path, "\n", MAX_RECORD_BYTES) do |chunk|
223
+ complete = chunk.end_with?("\n") || chunk.bytesize < MAX_RECORD_BYTES
224
+
225
+ unless complete
226
+ oversized_at ||= line_number + 1
227
+ next
228
+ end
229
+
230
+ if oversized_at
231
+ warn_about("record at line #{oversized_at} is too large to read " \
232
+ "(over #{MAX_RECORD_BYTES} bytes); skipped")
233
+ oversized_at = nil
234
+ line_number += 1
235
+ next
236
+ end
237
+
238
+ line_number += 1
239
+ record = parse(chunk, line_number)
240
+ yield record, line_number if record
241
+ end
242
+
243
+ warn_about("record at line #{oversized_at} is too large to read " \
244
+ "(over #{MAX_RECORD_BYTES} bytes); skipped") if oversized_at
245
+ rescue SystemCallError => e
246
+ warn_about("#{record_path} could not be read (#{e.class.name.split("::").last})")
247
+ end
248
+
249
+ def parse(chunk, line_number)
250
+ record = JSON.parse(chunk)
251
+ return record if record.is_a?(Hash)
252
+
253
+ warn_about("line #{line_number} is not a JSON object; skipped")
254
+ rescue JSON::ParserError, EncodingError
255
+ warn_about("line #{line_number} is not valid JSON; skipped")
256
+ end
257
+
258
+ # A token count is a whole number or absent. The type check is rule 2's
259
+ # container check applied to numbers: a format that writes "1234" as a
260
+ # String, or null, or a float where a count belongs, yields nil here
261
+ # rather than a value that would poison a sum three callers later.
262
+ def count_from(value) = value.is_a?(Integer) ? value : nil
263
+
264
+ # Cost arrives as a Float (or an Integer zero) where an agent reports
265
+ # it at all. Same guard, wider type: a cost is money, not a count, and
266
+ # 0 is a real answer — a subscription session genuinely costs $0
267
+ # marginal — so only a non-number is absent.
268
+ def cost_from(value) = value.is_a?(Numeric) ? value : nil
269
+
270
+ # Agents write ISO 8601 with a Z suffix. nil beats a wrong guess: a
271
+ # timestamp that cannot be parsed is missing, not epoch zero.
272
+ def time_from(value)
273
+ return nil unless value.is_a?(String)
274
+
275
+ Time.iso8601(value)
276
+ rescue ArgumentError
277
+ nil
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end