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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Readers
6
+ # Gemini CLI chat files. Written against a real store on this machine
7
+ # (2026-08-24): 12 sessions, 121 records — user 20, gemini 97, info 4.
8
+ #
9
+ # A chat is one JSON document, not JSONL, so it is read whole under a cap
10
+ # the way Amp's thread is. The cap is the reason this does not simply
11
+ # JSON.parse the file: the largest real chat here is 103 KB, but nothing
12
+ # in the format bounds it, and an unbounded read is the failure the base
13
+ # reader's chunked streaming exists to prevent.
14
+ class Gemini < Base
15
+ # Amp's bound, for the same reason: a whole document must fit in memory
16
+ # to be parsed at all, so the only protection available is refusing to
17
+ # read one that is absurdly large.
18
+ MAX_DOCUMENT_BYTES = 32_000_000
19
+
20
+ # "gemini" is the assistant. "info" is the CLI talking to the user
21
+ # ("Update successful! The new version will be used on your next run."),
22
+ # which is neither turn — context the operator saw, the same judgement
23
+ # Claude's system records get, so it arrives with include_events.
24
+ ROLES = { "user" => :user, "gemini" => :assistant }.freeze
25
+
26
+ # The document's own header, exposed because Layer 2's session id is the
27
+ # filename (the trailing hex in it is shared between sessions) while the
28
+ # agent's own sessionId lives in here.
29
+ def header
30
+ document.reject { |key, _| key == "messages" }
31
+ end
32
+
33
+ # Session totals, summed per message — the counts are per API call, not
34
+ # a running total (verified: the real series falls as well as rises,
35
+ # 64138 then 8069 then 8265, which no cumulative counter does).
36
+ def usage
37
+ total = nil
38
+ each_record do |record, _index|
39
+ usage = usage_from(record)
40
+ next unless usage
41
+
42
+ total = total ? total + usage : usage
43
+ end
44
+ total
45
+ end
46
+
47
+ private
48
+
49
+ # Overridden wholesale, like Amp's: the unit here is an element of the
50
+ # document's messages array, not a line of the file.
51
+ def each_record
52
+ Array(document["messages"]).each_with_index do |record, index|
53
+ next unless record.is_a?(Hash)
54
+
55
+ yield record, index + 1
56
+ end
57
+ end
58
+
59
+ def document
60
+ @document ||= begin
61
+ size = File.size(session.path)
62
+ if size > MAX_DOCUMENT_BYTES
63
+ warn_about("#{session.path} is larger than #{MAX_DOCUMENT_BYTES} bytes; not read")
64
+ {}
65
+ else
66
+ parsed = JSON.parse(File.read(session.path))
67
+ parsed.is_a?(Hash) ? parsed : warn_about("#{session.path} is not a JSON object") || {}
68
+ end
69
+ rescue SystemCallError
70
+ warn_about("#{session.path} could not be read") || {}
71
+ rescue JSON::ParserError
72
+ warn_about("#{session.path} does not hold valid JSON") || {}
73
+ end
74
+ end
75
+
76
+ def message_for(record, index)
77
+ type = record["type"]
78
+ return event_message(record) if type == "info"
79
+
80
+ role = ROLES[type]
81
+ unless role
82
+ warn_about("message #{index}: unrecognized type #{type.inspect}")
83
+ return build(record, :unknown, [Part.new(type: :unknown, text: record["content"])])
84
+ end
85
+
86
+ build(record, role, content_parts(record))
87
+ end
88
+
89
+ # content is a plain String; thoughts and toolCalls sit beside it rather
90
+ # than inside a parts array, so the document's shape is flattened into
91
+ # this gem's vocabulary here rather than mapped one-to-one.
92
+ #
93
+ # A thought is {subject, description}: the subject alone reads as a
94
+ # heading with no body, so both are kept, joined — losing the
95
+ # description would make :thinking parts look empty.
96
+ def content_parts(record)
97
+ parts = []
98
+ Array(record["thoughts"]).each do |thought|
99
+ next unless thought.is_a?(Hash)
100
+
101
+ parts << Part.new(type: :thinking, text: [thought["subject"], thought["description"]]
102
+ .compact.join(": "))
103
+ end
104
+ content = record["content"]
105
+ parts << Part.new(type: :text, text: content.to_s) if content.is_a?(String) && !content.empty?
106
+ parts.concat(tool_parts(record))
107
+ parts
108
+ end
109
+
110
+ # One toolCalls entry holds both the call and its result — the same
111
+ # shape opencode's `tool` part has — so it becomes two Parts. The result
112
+ # appears only when the entry carries one: an entry still running
113
+ # answered nothing, and an empty result would claim it did.
114
+ def tool_parts(record)
115
+ Array(record["toolCalls"]).flat_map do |call|
116
+ next [] unless call.is_a?(Hash)
117
+
118
+ parts = [Part.new(type: :tool_use, name: call["name"], call_id: call["id"],
119
+ text: stringify(call["args"]))]
120
+ parts << Part.new(type: :tool_result, call_id: call["id"],
121
+ text: stringify(call["result"])) if call.key?("result")
122
+ parts
123
+ end
124
+ end
125
+
126
+ def event_message(record)
127
+ return nil unless include_events
128
+
129
+ build(record, :system, [Part.new(type: :text, text: record["content"].to_s)])
130
+ end
131
+
132
+ def build(record, role, parts)
133
+ model = record["model"]
134
+ Message.new(role: role, at: time_from(record["timestamp"]), parts: parts, raw: record,
135
+ usage: usage_from(record), model: model.is_a?(String) ? model : nil)
136
+ end
137
+
138
+ # tokens: {input, output, cached, thoughts, tool, total}.
139
+ #
140
+ # `cached` is INSIDE `input`, not beside it — verified arithmetically
141
+ # across every one of the 97 real token records: total equals
142
+ # input + output + thoughts + tool, with cached never added, so a cached
143
+ # count is part of the input it accompanies. Subtracted here for the
144
+ # same reason Codex's cached_input_tokens is, so Usage#input means one
145
+ # thing across agents. Clamped at zero: a negative token count would
146
+ # mean the two fields disagree, and a wrong zero beats a negative.
147
+ #
148
+ # `total` is deliberately unmapped (it restates the others), and so is
149
+ # `tool` — this gem's Usage has no bucket for tokens spent inside a
150
+ # tool, and folding them into output would misreport what the model
151
+ # generated. Both stay reachable in raw.
152
+ def usage_from(record)
153
+ tokens = record["tokens"]
154
+ return nil unless tokens.is_a?(Hash)
155
+
156
+ input = count_from(tokens["input"])
157
+ cached = count_from(tokens["cached"])
158
+ mapped = Usage.new(input: input && cached ? [input - cached, 0].max : input,
159
+ output: count_from(tokens["output"]),
160
+ cache_read: cached,
161
+ reasoning: count_from(tokens["thoughts"]))
162
+ mapped.to_h.each_value.any? ? mapped : nil
163
+ end
164
+
165
+ def stringify(value)
166
+ value.is_a?(String) || value.nil? ? value.to_s : JSON.generate(value)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Readers
6
+ # Grok Build sessions. PROVISIONAL, like the adapter: written against
7
+ # tokentelemetry's parser of this format, not against real Grok output.
8
+ #
9
+ # Two things make this reader unlike every other one here. The session it
10
+ # is handed points at summary.json, while the conversation is in
11
+ # chat_history.jsonl beside it — so the streaming base reads a SIBLING
12
+ # file. And billed usage is not in the session directory at all: it lives
13
+ # in ~/.grok/logs/unified.jsonl, one row per request across every session,
14
+ # keyed by session id. A rotated log means no usage, which is why `usage`
15
+ # answers nil rather than zero when the log is gone.
16
+ class Grok < Base
17
+ TRANSCRIPT = "chat_history.jsonl"
18
+ UNIFIED_LOG = "unified.jsonl"
19
+
20
+ # The row that records one completed request, per the reference parser.
21
+ INFERENCE = "shell.turn.inference_done"
22
+
23
+ ROLES = { "user" => :user, "assistant" => :assistant, "system" => :system,
24
+ "tool" => :tool }.freeze
25
+
26
+ # The session's summary.json, exposed because it holds what Layer 2 does
27
+ # not surface: generated_title, session_summary, current_model_id, the
28
+ # git branch and commit the work happened on.
29
+ def summary
30
+ @summary ||= begin
31
+ parsed = JSON.parse(File.read(session.path))
32
+ parsed.is_a?(Hash) ? parsed : {}
33
+ rescue SystemCallError, JSON::ParserError
34
+ warn_about("#{session.path} could not be read")
35
+ {}
36
+ end
37
+ end
38
+
39
+ # Summed across this session's rows in the shared inference log.
40
+ #
41
+ # prompt_tokens INCLUDES cached_prompt_tokens (the reference parser
42
+ # subtracts one from the other, as this gem does for Codex and Gemini),
43
+ # so `input` is the difference and `cache_read` the cached share. The
44
+ # cached count is clamped to the prompt first: a log row claiming more
45
+ # cached than prompt would otherwise produce a negative input.
46
+ def usage
47
+ rows = 0
48
+ input = output = cached = reasoning = 0
49
+ each_log_row do |record|
50
+ ctx = record["ctx"]
51
+ next unless ctx.is_a?(Hash)
52
+
53
+ prompt = count_from(ctx["prompt_tokens"]).to_i
54
+ hit = [count_from(ctx["cached_prompt_tokens"]).to_i, prompt].min
55
+ rows += 1
56
+ input += prompt - hit
57
+ cached += hit
58
+ output += count_from(ctx["completion_tokens"]).to_i
59
+ reasoning += count_from(ctx["reasoning_tokens"]).to_i
60
+ end
61
+ return nil if rows.zero?
62
+
63
+ Usage.new(input: input, output: output, cache_read: cached, reasoning: reasoning)
64
+ end
65
+
66
+ private
67
+
68
+ # The conversation, beside the summary Layer 2 pointed at. This is the
69
+ # hook Readers::Base provides for exactly this case, so all of its
70
+ # streaming and reporting still applies.
71
+ def record_path = File.join(File.dirname(session.path), TRANSCRIPT)
72
+
73
+ # <base>/sessions/<project>/<id>/summary.json → <base>/logs/unified.jsonl.
74
+ # Four levels up rather than a stored root, because a Session carries a
75
+ # path and nothing else about where its store began.
76
+ def unified_log_path
77
+ base = File.dirname(File.dirname(File.dirname(File.dirname(session.path))))
78
+ File.join(base, "logs", UNIFIED_LOG)
79
+ end
80
+
81
+ # Streams the shared log, keeping only this session's completed
82
+ # requests. The whole file is walked because rows for many sessions are
83
+ # interleaved; the cheap string check comes before the JSON parse, since
84
+ # most rows belong to other sessions or other event types.
85
+ def each_log_row
86
+ path = unified_log_path
87
+ return unless File.exist?(path)
88
+
89
+ File.foreach(path, "\n", MAX_RECORD_BYTES) do |chunk|
90
+ next unless chunk.include?(INFERENCE) && chunk.include?(session.id)
91
+
92
+ record = begin
93
+ JSON.parse(chunk)
94
+ rescue JSON::ParserError, EncodingError
95
+ next
96
+ end
97
+ next unless record.is_a?(Hash) && record["msg"] == INFERENCE && record["sid"] == session.id
98
+
99
+ yield record
100
+ end
101
+ rescue SystemCallError
102
+ warn_about("#{path} could not be read; token usage is unavailable for this session")
103
+ end
104
+
105
+ def message_for(record, line_number)
106
+ role = ROLES[record["role"]]
107
+ unless role
108
+ warn_about("line #{line_number}: unrecognized role #{record["role"].inspect}")
109
+ return Message.new(role: :unknown, at: time_from(record["timestamp"]),
110
+ parts: [Part.new(type: :unknown)], raw: record)
111
+ end
112
+
113
+ Message.new(role: role, at: time_from(record["timestamp"]), parts: content_parts(record),
114
+ raw: record, usage: nil, model: model_for(record))
115
+ end
116
+
117
+ # content is a String, or an array of typed blocks of which only text is
118
+ # mapped by the reference parser — anything else stays in raw rather
119
+ # than being guessed at.
120
+ def content_parts(record)
121
+ content = record["content"]
122
+ return [Part.new(type: :text, text: content)] if content.is_a?(String)
123
+
124
+ Array(content).filter_map do |block|
125
+ next unless block.is_a?(Hash)
126
+
127
+ case block["type"]
128
+ when "text" then Part.new(type: :text, text: block["text"].to_s)
129
+ when "tool_use", "tool_call"
130
+ Part.new(type: :tool_use, name: block["name"], call_id: block["id"],
131
+ text: stringify(block["input"] || block["arguments"]))
132
+ when "tool_result"
133
+ Part.new(type: :tool_result, call_id: block["tool_use_id"] || block["id"],
134
+ text: stringify(block["content"] || block["output"]))
135
+ when "thinking", "reasoning"
136
+ Part.new(type: :thinking, text: (block["thinking"] || block["text"]).to_s)
137
+ end
138
+ end
139
+ end
140
+
141
+ # Per-message model where one is recorded, falling back to the session's
142
+ # current model from summary.json — which is what it says it is, the
143
+ # model in force now, so it is a fallback and never an override.
144
+ def model_for(record)
145
+ model = record["model"] || summary["current_model_id"]
146
+ model.is_a?(String) ? model : nil
147
+ end
148
+
149
+ def stringify(value)
150
+ value.is_a?(String) || value.nil? ? value.to_s : JSON.generate(value)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Readers
6
+ # opencode sessions, read from the shared SQLite database the adapter
7
+ # already enumerates. Written against a real store on this machine
8
+ # (2026-08-24): 365 sessions, whose message and part rows settled every
9
+ # mapping below — this is the first reader whose "corpus" is a database
10
+ # rather than files.
11
+ #
12
+ # A "record" here is synthetic: one message row's parsed `data` plus every
13
+ # part row belonging to it, as {"message" => ..., "parts" => [...]}. That
14
+ # composite IS the raw a Message carries — rule 1 needs the parts included,
15
+ # because the content lives in them, not in the message row.
16
+ class Opencode < Base
17
+ # Conversation content. text and reasoning map 1:1; a `tool` part holds
18
+ # BOTH the call and its result in one row (state.input / state.output),
19
+ # so it becomes two Parts — the assistant's act and the tool answering —
20
+ # rather than flattening one of them away.
21
+ CONTENT_PARTS = %w[text reasoning tool].freeze
22
+
23
+ # State, not conversation, skipped in silence — the same judgement
24
+ # Claude's session-state records get. Observed counts in the real store:
25
+ # step-start 4,391, step-finish 4,380 (consumed for usage below), patch
26
+ # 569 (files a step touched), file 25 (attachments), agent 1, compaction
27
+ # 1 (surfaced through `compactions`, not as a message).
28
+ STATE_PARTS = %w[step-start step-finish patch file agent compaction snapshot].freeze
29
+
30
+ # Session totals, summed per message. No dedup is needed: one row is one
31
+ # API response, and the sum was verified against the store's own
32
+ # per-session rollup columns — 9,727,437 input / 94,266 output /
33
+ # 22,184,157 cache-read, exactly equal both ways on the real store.
34
+ def usage
35
+ total = nil
36
+ each_record do |record, _row_number|
37
+ usage = usage_from(record)
38
+ next unless usage
39
+
40
+ total = total ? total + usage : usage
41
+ end
42
+ total
43
+ end
44
+
45
+ private
46
+
47
+ # Message rows for this session, oldest first. time_created is epoch
48
+ # millis; id is the tiebreak so two messages written in the same
49
+ # millisecond keep a stable order. Parts are fetched per message rather
50
+ # than per session: a session's tool outputs can be arbitrarily large,
51
+ # and rule 3 (never assume it fits in memory) applies to a database
52
+ # exactly as it does to a 2.6 GB file.
53
+ #
54
+ # A failure to read the DATABASE warns and yields nothing — one
55
+ # unreadable session must not take down a sweep — unlike the adapter,
56
+ # which raises UnreadableStore because enumeration has nothing partial
57
+ # to return. A missing sqlite3 gem still raises: that is the caller's
58
+ # environment, not this session's data.
59
+ def each_record
60
+ require_sqlite!
61
+ db = nil
62
+ row_number = 0
63
+ begin
64
+ db = Sqlite.open_readonly(session.path)
65
+ db.execute("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created, id",
66
+ [session.id]) do |(id, data)|
67
+ row_number += 1
68
+ message = parse_row(data, "message #{id}") or next
69
+ yield({ "message" => message, "parts" => parts_rows(db, id) }, row_number)
70
+ end
71
+ rescue SQLite3::Exception => e
72
+ warn_about("#{session.path} could not be read (#{e.class.name.split("::").last})")
73
+ ensure
74
+ db&.close
75
+ end
76
+ end
77
+
78
+ def parts_rows(db, message_id)
79
+ db.execute("SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created, id",
80
+ [message_id]).filter_map { |(id, data)| parse_row(data, "part #{id}") }
81
+ end
82
+
83
+ def parse_row(data, label)
84
+ record = JSON.parse(data)
85
+ return record if record.is_a?(Hash)
86
+
87
+ warn_about("#{label} is not a JSON object; skipped")
88
+ rescue JSON::ParserError, TypeError
89
+ warn_about("#{label} does not hold valid JSON; skipped")
90
+ end
91
+
92
+ def message_for(record, row_number)
93
+ data = record["message"]
94
+ role = data["role"]
95
+ unless %w[user assistant].include?(role)
96
+ warn_about("message #{row_number}: unrecognized role #{role.inspect}")
97
+ return build(record, :unknown)
98
+ end
99
+
100
+ build(record, role.to_sym)
101
+ end
102
+
103
+ def build(record, role)
104
+ data = record["message"]
105
+ Message.new(role: role, at: epoch_ms(data.dig("time", "created")),
106
+ parts: content_parts(record), raw: record,
107
+ usage: usage_from(record), model: model_from(data))
108
+ end
109
+
110
+ def content_parts(record)
111
+ record["parts"].flat_map do |part|
112
+ type = part["type"]
113
+ next [] if STATE_PARTS.include?(type)
114
+
115
+ case type
116
+ when "text" then [Part.new(type: :text, text: part["text"].to_s)]
117
+ when "reasoning" then [Part.new(type: :thinking, text: part["text"].to_s)]
118
+ when "tool" then tool_parts(part)
119
+ # A subagent spawn: {prompt, description, agent, model, command} —
120
+ # found by running this reader over all 365 real sessions and
121
+ # reading its one warning. The assistant's act of delegating, so
122
+ # :tool_use like Claude's Task; the child's turns are its own
123
+ # session row (parent_id), never inlined here.
124
+ when "subtask"
125
+ [Part.new(type: :tool_use, name: part["agent"] || "subtask", text: part["prompt"].to_s)]
126
+ else
127
+ warn_about("unrecognized part type #{type.inspect}")
128
+ [Part.new(type: :unknown, text: part["text"])]
129
+ end
130
+ end
131
+ end
132
+
133
+ # state.input is a Hash (the tool's arguments), state.output a String.
134
+ # The result Part appears only when the state carries an output — a
135
+ # pending or errored call answered nothing, and an empty result would
136
+ # claim it did.
137
+ def tool_parts(part)
138
+ state = part["state"]
139
+ state = {} unless state.is_a?(Hash)
140
+ parts = [Part.new(type: :tool_use, name: part["tool"], call_id: part["callID"],
141
+ text: stringify(state["input"]))]
142
+ parts << Part.new(type: :tool_result, call_id: part["callID"],
143
+ text: state["output"].to_s) if state.key?("output")
144
+ parts
145
+ end
146
+
147
+ # An assistant row carries its own tokens and cost (every one of the
148
+ # 4,000+ assistant rows in the real store does). The step-finish
149
+ # fallback covers the schema generation tokentelemetry observed, where
150
+ # only parts carried tokens; the two sources are per-message equal where
151
+ # both exist (verified: 10,557/221/489 both ways on a real message), so
152
+ # preferring the message row can never double-count.
153
+ def usage_from(record)
154
+ data = record["message"]
155
+ tokens = data["tokens"]
156
+ return tokens_usage(tokens, data["cost"]) if tokens.is_a?(Hash)
157
+
158
+ step_usage(record["parts"])
159
+ end
160
+
161
+ def tokens_usage(tokens, cost)
162
+ mapped = Usage.new(input: count_from(tokens["input"]),
163
+ output: count_from(tokens["output"]),
164
+ reasoning: count_from(tokens["reasoning"]),
165
+ cache_read: count_from(tokens.dig("cache", "read")),
166
+ cache_creation: count_from(tokens.dig("cache", "write")),
167
+ cost: cost_from(cost))
168
+ mapped.to_h.each_value.any? ? mapped : nil
169
+ end
170
+
171
+ def step_usage(parts)
172
+ total = nil
173
+ parts.each do |part|
174
+ next unless part["type"] == "step-finish" && part["tokens"].is_a?(Hash)
175
+
176
+ usage = tokens_usage(part["tokens"], part["cost"])
177
+ next unless usage
178
+
179
+ total = total ? total + usage : usage
180
+ end
181
+ total
182
+ end
183
+
184
+ def model_from(data)
185
+ model = data["modelID"] # an assistant row's spelling
186
+ model = data.dig("model", "modelID") unless model.is_a?(String) # a user row's
187
+ model.is_a?(String) ? model : nil
188
+ end
189
+
190
+ # {"type":"compaction","auto":false} is the whole record observed — no
191
+ # count of what it replaced, so replaced_count is nil rather than a zero
192
+ # that would read as "stood in for nothing".
193
+ def compaction_for(record)
194
+ part = record["parts"].find { |candidate| candidate["type"] == "compaction" }
195
+ return nil unless part
196
+
197
+ Compaction.new(at: epoch_ms(record["message"].dig("time", "created")),
198
+ replaced_count: nil, raw: part)
199
+ end
200
+
201
+ def stringify(value)
202
+ value.is_a?(String) || value.nil? ? value.to_s : JSON.generate(value)
203
+ end
204
+
205
+ # Epoch millis to Time, with the adapter's session_time guards: a
206
+ # non-Numeric is nil, and a huge-but-real Integer that overflows to
207
+ # Infinity once divided must not reach Time.at.
208
+ def epoch_ms(millis)
209
+ return nil unless millis.is_a?(Numeric)
210
+
211
+ seconds = millis / 1000.0
212
+ Time.at(seconds) if seconds.finite?
213
+ end
214
+
215
+ def require_sqlite!
216
+ require "sqlite3"
217
+ rescue LoadError
218
+ raise MissingDependency,
219
+ "opencode messages live in opencode.db (SQLite); add the sqlite3 gem to read them"
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Readers
6
+ # pi session files. PROVISIONAL in a way no other reader is: this machine
7
+ # holds nine real pi project directories and zero session files inside
8
+ # them (2026-08-24), so every mapping below is written against
9
+ # tokentelemetry's working parser of the same format
10
+ # (resources/tokentelemetry, backend/main.py, _scan_pi_sessions) rather
11
+ # than a corpus of pi's own output. That is observation of running code,
12
+ # not of data — one step better than the design doc's prose, one step
13
+ # short of every other reader's evidence. Where the two could disagree,
14
+ # rule 2 already decides the outcome: a shape this reader has not seen
15
+ # becomes an :unknown part and a warning, never an exception, and raw
16
+ # carries what really happened.
17
+ #
18
+ # The format per that parser: a header record {"type":"session", id, cwd,
19
+ # timestamp}, then typed records — "model_change" (provider, modelId) and
20
+ # "message" ({role, model, content[], usage}). usage spells its keys
21
+ # camelCase (cacheRead, cacheWrite) and carries agent-computed cost.
22
+ class Pi < Base
23
+ # The header and settings records are session state, not conversation —
24
+ # the same judgement Codex's session_meta gets. model_change is state
25
+ # too: the model a LATER message used is on that message.
26
+ NON_MESSAGE_TYPES = %w[session model_change].freeze
27
+
28
+ ROLES = { "user" => :user, "assistant" => :assistant }.freeze
29
+
30
+ # Session totals, summed per message record. No dedup: nothing observed
31
+ # or reported suggests pi repeats one response across records the way
32
+ # Claude does — but nothing proves it either, so if pi totals ever read
33
+ # roughly double a provider's bill, this is where to look.
34
+ def usage
35
+ total = nil
36
+ each_record do |record, _line_number|
37
+ usage = usage_from(record)
38
+ next unless usage
39
+
40
+ total = total ? total + usage : usage
41
+ end
42
+ total
43
+ end
44
+
45
+ private
46
+
47
+ def message_for(record, line_number)
48
+ type = record["type"]
49
+ return nil if NON_MESSAGE_TYPES.include?(type)
50
+
51
+ unless type == "message" && record["message"].is_a?(Hash)
52
+ warn_about("line #{line_number}: unrecognized record type #{type.inspect}")
53
+ return Message.new(role: :unknown, at: time_from(record["timestamp"]),
54
+ parts: [Part.new(type: :unknown)], raw: record)
55
+ end
56
+
57
+ data = record["message"]
58
+ Message.new(role: role_for(data["role"], line_number),
59
+ at: time_from(record["timestamp"]),
60
+ parts: content_parts(data, line_number), raw: record,
61
+ usage: usage_from(record), model: model_from(data))
62
+ end
63
+
64
+ def role_for(role, line_number)
65
+ ROLES.fetch(role) do
66
+ warn_about("line #{line_number}: unrecognized role #{role.inspect}")
67
+ :unknown
68
+ end
69
+ end
70
+
71
+ # content carries text and toolCall items per the reference parser. A
72
+ # toolCall's inner keys are the least-verified mapping in this file —
73
+ # name/id/arguments are the spellings pi's TypeScript types suggest, and
74
+ # every one is fetched nil-safe so a different spelling degrades to an
75
+ # emptier Part, never to a crash. raw holds the truth either way.
76
+ def content_parts(data, line_number)
77
+ Array(data["content"]).map do |item|
78
+ next Part.new(type: :unknown) unless item.is_a?(Hash)
79
+
80
+ case item["type"]
81
+ when "text" then Part.new(type: :text, text: item["text"].to_s)
82
+ when "thinking" then Part.new(type: :thinking, text: item["thinking"].to_s)
83
+ when "toolCall"
84
+ Part.new(type: :tool_use, name: item["name"], call_id: item["id"],
85
+ text: stringify(item["arguments"] || item["input"]))
86
+ when "toolResult"
87
+ Part.new(type: :tool_result, call_id: item["toolCallId"] || item["id"],
88
+ text: item["output"].is_a?(String) ? item["output"] : item["text"])
89
+ else
90
+ warn_about("line #{line_number}: unrecognized content part #{item["type"].inspect}")
91
+ Part.new(type: :unknown, text: item["text"])
92
+ end
93
+ end
94
+ end
95
+
96
+ # usage keys observed by the reference parser: input, output, cacheRead,
97
+ # cacheWrite, reasoning, totalTokens, cost. totalTokens is deliberately
98
+ # unmapped — it restates the others, and any bucket it landed in would
99
+ # double-count. Whether input already excludes cacheRead the way the
100
+ # field names suggest (Anthropic-style disjoint spelling) is UNVERIFIED;
101
+ # if pi turns out to count inclusively the way Codex does, the fix is a
102
+ # subtraction here, not in any caller.
103
+ def usage_from(record)
104
+ usage = record.dig("message", "usage")
105
+ return nil unless usage.is_a?(Hash)
106
+
107
+ cost = usage["cost"]
108
+ cost = cost["total"] if cost.is_a?(Hash)
109
+ mapped = Usage.new(input: count_from(usage["input"]),
110
+ output: count_from(usage["output"]),
111
+ cache_read: count_from(usage["cacheRead"]),
112
+ cache_creation: count_from(usage["cacheWrite"]),
113
+ reasoning: count_from(usage["reasoning"]),
114
+ cost: cost_from(cost))
115
+ mapped.to_h.each_value.any? ? mapped : nil
116
+ end
117
+
118
+ def model_from(data)
119
+ model = data["model"]
120
+ model.is_a?(String) ? model : nil
121
+ end
122
+
123
+ def stringify(value)
124
+ value.is_a?(String) || value.nil? ? value.to_s : JSON.generate(value)
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end