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,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
|