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