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,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
# Cursor's IDE agent (Composer). Repointed 2026-08-24 at the store the
|
|
7
|
+
# 0.2 adapter's own warning named as the real one, now that it has been
|
|
8
|
+
# opened rather than inferred: ~/Library/Application Support/Cursor/User/
|
|
9
|
+
# globalStorage/state.vscdb, table cursorDiskKV, keys composerData:<uuid>
|
|
10
|
+
# — 6 real rows on the machine this was written on. The 0.2 declaration
|
|
11
|
+
# (~/.cursor/projects/*/agent-transcripts/*) does not exist there at all,
|
|
12
|
+
# so this adapter reported nothing for an agent that had sessions.
|
|
13
|
+
#
|
|
14
|
+
# What is verified: the file, the table, the key prefix, and the record's
|
|
15
|
+
# own composerId/createdAt. What is NOT: anything about the conversation
|
|
16
|
+
# itself — all six records on this machine carry "conversation": [], so
|
|
17
|
+
# the shape of a turn has never been seen here. fidelity stays :metadata
|
|
18
|
+
# and no reader exists, which is the honest report: this adapter can say
|
|
19
|
+
# a session happened and when, and must not pretend to say what was said.
|
|
20
|
+
class CursorIde < Base
|
|
21
|
+
agent :cursor_ide
|
|
22
|
+
label "Cursor IDE"
|
|
23
|
+
documented false
|
|
24
|
+
verified_on "2026-08-24"
|
|
25
|
+
fidelity :metadata
|
|
26
|
+
|
|
27
|
+
homedir :cursor_ide, join: "User/globalStorage"
|
|
28
|
+
|
|
29
|
+
store :database, path: "state.vscdb", format: :sqlite
|
|
30
|
+
|
|
31
|
+
warning "the IDE composer store does not sync with the CLI chat store"
|
|
32
|
+
warning "session content is not read: every composerData record seen carried an " \
|
|
33
|
+
"empty conversation, so the turn format remains unverified"
|
|
34
|
+
|
|
35
|
+
KEY_PREFIX = "composerData:"
|
|
36
|
+
|
|
37
|
+
# Rows, not files, so Base's glob enumeration is replaced the way
|
|
38
|
+
# opencode's is — including the existence check FIRST, so a machine
|
|
39
|
+
# without Cursor never needs the sqlite3 gem at all.
|
|
40
|
+
#
|
|
41
|
+
# value is parsed for createdAt alone. It is the whole composer document
|
|
42
|
+
# (context, capabilities, code blocks), which is why `bytes` stays nil:
|
|
43
|
+
# a row in a shared database has no file size of its own, and the
|
|
44
|
+
# database's size belongs to all 7 rows together.
|
|
45
|
+
def sessions
|
|
46
|
+
db_path = primary_layer.path
|
|
47
|
+
return [].lazy unless File.exist?(db_path)
|
|
48
|
+
|
|
49
|
+
Enumerator.new do |yielder|
|
|
50
|
+
each_composer_row(db_path) do |key, value|
|
|
51
|
+
yielder << build_row_session(db_path, key, value)
|
|
52
|
+
end
|
|
53
|
+
end.lazy
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Cursor's composer records do not name a project. context.fileSelections
|
|
57
|
+
# holds paths of files ATTACHED to a turn — on this machine, a settings
|
|
58
|
+
# file from an unrelated directory — and a workspace root inferred from
|
|
59
|
+
# one attachment would be a guess dressed as a fact. nil is the honest
|
|
60
|
+
# answer, and `projects` reporting nothing for this agent is correct
|
|
61
|
+
# rather than empty-looking.
|
|
62
|
+
def project_path_for(_path) = nil
|
|
63
|
+
|
|
64
|
+
def project_paths = []
|
|
65
|
+
|
|
66
|
+
def sessions_for_project(_dir) = [].lazy
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def build_row_session(db_path, key, value)
|
|
71
|
+
created = parse_created_at(value)
|
|
72
|
+
Session.new(
|
|
73
|
+
agent: self.class.agent_name, id: key.delete_prefix(KEY_PREFIX), path: db_path,
|
|
74
|
+
project_path: nil, started_at: created,
|
|
75
|
+
updated_at: created || db_mtime(db_path), bytes: nil,
|
|
76
|
+
format: primary_layer.format, fidelity: self.class.fidelity_value
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# createdAt is epoch milliseconds (1776161422165 in real rows). The
|
|
81
|
+
# guards are opencode's, for the identical failures: a non-Numeric value
|
|
82
|
+
# is nil rather than a wrong time, and a huge-but-real Integer that
|
|
83
|
+
# overflows to Infinity once divided must never reach Time.at, which
|
|
84
|
+
# raises FloatDomainError and would take the whole listing down.
|
|
85
|
+
def parse_created_at(value)
|
|
86
|
+
millis = read_json_value(value)["createdAt"]
|
|
87
|
+
return nil unless millis.is_a?(Numeric)
|
|
88
|
+
|
|
89
|
+
seconds = millis / 1000.0
|
|
90
|
+
Time.at(seconds) if seconds.finite?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read_json_value(value)
|
|
94
|
+
parsed = JSON.parse(value.to_s)
|
|
95
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
96
|
+
rescue JSON::ParserError, TypeError
|
|
97
|
+
{}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# updated_at may never be nil (the cross-adapter invariant `since` and
|
|
101
|
+
# every sort rely on), and a composer record carries no updated
|
|
102
|
+
# timestamp at all — only createdAt. The database file's own mtime is
|
|
103
|
+
# not this session's time, but it is a true upper bound on when anything
|
|
104
|
+
# in the store last changed, and it is reached only when createdAt is
|
|
105
|
+
# missing or malformed.
|
|
106
|
+
def db_mtime(db_path)
|
|
107
|
+
@db_mtime ||= begin
|
|
108
|
+
File.mtime(db_path)
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
Time.now
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def each_composer_row(db_path, &block)
|
|
115
|
+
require_sqlite!
|
|
116
|
+
db = nil
|
|
117
|
+
begin
|
|
118
|
+
db = Sqlite.open_readonly(db_path)
|
|
119
|
+
db.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE ?", ["#{KEY_PREFIX}%"], &block)
|
|
120
|
+
rescue SQLite3::Exception => e
|
|
121
|
+
raise UnreadableStore, "#{db_path}: #{e.message}"
|
|
122
|
+
ensure
|
|
123
|
+
db&.close
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def require_sqlite!
|
|
128
|
+
require "sqlite3"
|
|
129
|
+
rescue LoadError
|
|
130
|
+
raise MissingDependency,
|
|
131
|
+
"Cursor IDE sessions live in state.vscdb (SQLite); add the sqlite3 gem to enumerate them"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
# Layer 2: turning a resolved store into sessions. Split out of Base once it
|
|
7
|
+
# held three concerns at 460 lines, and before Layer 3 readers add a fourth.
|
|
8
|
+
#
|
|
9
|
+
# Mixed into Base rather than included per adapter, so every adapter keeps
|
|
10
|
+
# inheriting all of this and overriding the hooks it needs — the extraction
|
|
11
|
+
# is a move, not a change in how an adapter is written.
|
|
12
|
+
#
|
|
13
|
+
# What this half needs from Layer 1 is deliberately small, and worth keeping
|
|
14
|
+
# small: `primary_layer` (the store to enumerate and the format to stamp on
|
|
15
|
+
# each session) and the class-level DSL readers `agent_name` and
|
|
16
|
+
# `fidelity_value`. Nothing here resolves a path, reads an env override, or
|
|
17
|
+
# touches @env. A method that needs to do any of those belongs in Base.
|
|
18
|
+
module Enumeration
|
|
19
|
+
# Caps how many bytes one iteration of a JSONL scan may pull into memory.
|
|
20
|
+
# "One line" is not a bounded quantity on disk: a record carrying a pasted
|
|
21
|
+
# file or a base64 image is routinely tens of MB, and a truncated file may
|
|
22
|
+
# hold no newline at all. An over-long line arrives as chunks of this size,
|
|
23
|
+
# which fail to parse and are skipped, so the scan gives up rather than
|
|
24
|
+
# reading a 2.6 GB file into a single String.
|
|
25
|
+
MAX_LINE_BYTES = 1_000_000
|
|
26
|
+
|
|
27
|
+
# Lazily enumerates the primary store. Each consumed session costs one stat
|
|
28
|
+
# plus filename parsing — never a content read. project_path is the exception
|
|
29
|
+
# and pays for itself on first access.
|
|
30
|
+
#
|
|
31
|
+
# A store the gem has no layout for is refused rather than reported empty:
|
|
32
|
+
# Location#enumerable? exists precisely so "nothing here to enumerate" and
|
|
33
|
+
# "enumerated, found none" stay distinguishable, and silently returning no
|
|
34
|
+
# sessions is this gem's worst failure mode.
|
|
35
|
+
def sessions
|
|
36
|
+
unless primary_layer.enumerable?
|
|
37
|
+
raise Error, "#{self.class.agent_name} store #{primary_layer.kind} at " \
|
|
38
|
+
"#{primary_layer.path} has no known layout to enumerate"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
enumerate(primary_layer.files)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Match by RECORDED cwd, exact, per session (design doc section 7,
|
|
45
|
+
# revised 2026-08-05 — the third design for this method, kept honest
|
|
46
|
+
# here because the next reader deserves to know why it is not "cheap").
|
|
47
|
+
# The first two designs were built and disproved against a real store,
|
|
48
|
+
# not in theory:
|
|
49
|
+
#
|
|
50
|
+
# 1. Directory-name matching (the original design) assumed a
|
|
51
|
+
# session's parent directory equals encode(its own recorded cwd).
|
|
52
|
+
# A project rename breaks that: the agent keeps writing under the
|
|
53
|
+
# OLD encoded directory, so two directories can hold live sessions
|
|
54
|
+
# for the SAME current cwd, and name-only matching silently
|
|
55
|
+
# dropped the stale one — false negatives, the failure mode this
|
|
56
|
+
# gem treats as worst (decision 11).
|
|
57
|
+
#
|
|
58
|
+
# 2. One-read-per-directory sampling (the first fix for #1) assumed
|
|
59
|
+
# sessions within a directory share a cwd, to keep the match
|
|
60
|
+
# sublinear. Reading a real renamed project's stale directory
|
|
61
|
+
# disproved that: two of its three sessions had been resumed after
|
|
62
|
+
# the rename and recorded the NEW cwd; the third was never resumed
|
|
63
|
+
# and still recorded the OLD one. Sampling one session and
|
|
64
|
+
# applying its verdict to the whole directory is wrong in BOTH
|
|
65
|
+
# directions on the same store — it invented a false positive
|
|
66
|
+
# here, and a different glob order would just as easily have
|
|
67
|
+
# reproduced #1's false negative for that same directory.
|
|
68
|
+
# Approximate cwd resolution doesn't make the error smaller; it
|
|
69
|
+
# just moves where it lands.
|
|
70
|
+
#
|
|
71
|
+
# Measured cost of reading every session instead of sampling: 0.17 ms
|
|
72
|
+
# per session (68 real Claude sessions, full sweep, 0.012s total) — 0.7s
|
|
73
|
+
# extrapolated to a 4,000-session store. That is what the sampling
|
|
74
|
+
# complexity was buying, and it is not a trade worth making: the
|
|
75
|
+
# enumerator is already lazy, so a caller taking first(n) never pays
|
|
76
|
+
# for sessions it never asked about, and even the worst case (every
|
|
77
|
+
# session checked, no match) stays under a second on a store two
|
|
78
|
+
# orders of magnitude larger than anything observed.
|
|
79
|
+
#
|
|
80
|
+
# A session whose own cwd cannot be read (the scan gave up, the file is
|
|
81
|
+
# unreadable, the adapter declares no reader) falls back to comparing
|
|
82
|
+
# ITS OWN directory's name against the encoding, when the adapter
|
|
83
|
+
# declares one — this is the only thing encode_project still buys: it
|
|
84
|
+
# keeps a session with an unreadable header from becoming invisible,
|
|
85
|
+
# without resolving an unknown project for every other session that
|
|
86
|
+
# happens to share its directory.
|
|
87
|
+
def sessions_for_project(dir)
|
|
88
|
+
dir = File.expand_path(dir)
|
|
89
|
+
encoded = encode_project(dir)
|
|
90
|
+
sessions.select do |session|
|
|
91
|
+
# expand_path does not resolve symlinks, so a cwd recorded as
|
|
92
|
+
# /private/tmp/x will not match a caller's /tmp/x on macOS.
|
|
93
|
+
# Deliberate: realpath would cost a stat per comparison to fix a
|
|
94
|
+
# rare mismatch.
|
|
95
|
+
cwd = session.project_path
|
|
96
|
+
next cwd == dir unless cwd.nil?
|
|
97
|
+
|
|
98
|
+
encoded && project_dir_name(session.path) == encoded
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Distinct recorded project paths, sorted. This is the read-everything
|
|
103
|
+
# direction (design doc section 7): the encodings cannot be reversed, so the
|
|
104
|
+
# recorded cwd inside each file is the only reliable source. Sessions whose
|
|
105
|
+
# project cannot be determined are excluded, not returned as nil.
|
|
106
|
+
#
|
|
107
|
+
# Sorted rather than left in glob order because a stable order is what makes
|
|
108
|
+
# `projects` output diffable and `du --by project` deterministic — and
|
|
109
|
+
# adapters answering from a database would otherwise impose their own.
|
|
110
|
+
def project_paths
|
|
111
|
+
sessions.filter_map(&:project_path).uniq.force.sort
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# --- Layer 2 hooks, overridable per adapter ---
|
|
115
|
+
|
|
116
|
+
def session_id_from(path)
|
|
117
|
+
File.basename(path, ".*")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# nil means this adapter has no directory-name fallback rule. Used only
|
|
121
|
+
# by sessions_for_project, and only for a session whose own recorded
|
|
122
|
+
# cwd could not be read. When overridden: dir arrives pre-expanded here
|
|
123
|
+
# from sessions_for_project (File.expand_path), which is the
|
|
124
|
+
# precondition an override may rely on — a direct caller must pass an
|
|
125
|
+
# absolute, expanded path itself, or the encoding is nonsense ("app",
|
|
126
|
+
# "~/app", and a trailing slash all encode differently from the
|
|
127
|
+
# canonical form real project directories were named from).
|
|
128
|
+
def encode_project(_dir) = nil
|
|
129
|
+
|
|
130
|
+
# The directory whose name the encoding must match, when
|
|
131
|
+
# sessions_for_project falls back to it. Overridable: not every store
|
|
132
|
+
# puts the encoded project directly above the session file — cursor_ide
|
|
133
|
+
# nests projects/<name>/agent-transcripts/*, where the immediate parent
|
|
134
|
+
# is agent-transcripts and matching it would find nothing, silently.
|
|
135
|
+
def project_dir_name(path) = File.basename(File.dirname(path))
|
|
136
|
+
|
|
137
|
+
# nil means the project is unknown for this session. Adapters override
|
|
138
|
+
# with a bounded read of their own metadata; Base cannot guess.
|
|
139
|
+
def project_path_for(_path) = nil
|
|
140
|
+
|
|
141
|
+
# Both time hooks take the stat the enumerator already holds, so a session
|
|
142
|
+
# still costs one syscall. path is passed for adapters that answer from a
|
|
143
|
+
# sibling metadata file instead.
|
|
144
|
+
#
|
|
145
|
+
# nil beats a wrong guess when the filesystem cannot answer at all — and it
|
|
146
|
+
# says so through ENOSYS/EPERM from statx as often as NotImplementedError.
|
|
147
|
+
def started_at_for(_path, stat)
|
|
148
|
+
stat.birthtime
|
|
149
|
+
rescue NotImplementedError, SystemCallError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def updated_at_for(_path, stat) = stat.mtime
|
|
154
|
+
|
|
155
|
+
# Bytes this session occupies on disk. The transcript alone for a store
|
|
156
|
+
# that keeps one file per session; an adapter whose agent writes sidecar
|
|
157
|
+
# files beside the transcript overrides this and adds them. Like the two
|
|
158
|
+
# time hooks it takes the stat the enumerator already holds, so the
|
|
159
|
+
# common case still costs nothing beyond the syscall already made.
|
|
160
|
+
#
|
|
161
|
+
# An override runs EAGERLY for every session, so it carries build_session's
|
|
162
|
+
# constraint: it must not raise on an unreadable path, or one bad sidecar
|
|
163
|
+
# takes down the whole listing rather than its own row.
|
|
164
|
+
def bytes_for(_path, stat) = stat.size
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Turns paths into sessions, lazily. Extracted so an adapter whose agent
|
|
169
|
+
# writes sessions to more than one store can enumerate the others without
|
|
170
|
+
# copying `sessions`' guard clause — see Codex, which chains its archived
|
|
171
|
+
# store onto this. The glob behind `paths` has already run; what stays
|
|
172
|
+
# lazy is the stat and the hooks, which is where the per-session cost is.
|
|
173
|
+
def enumerate(paths)
|
|
174
|
+
paths.lazy.filter_map { |path| build_session(path) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# nil drops the session from the enumeration. A file that vanished between
|
|
178
|
+
# the glob and its stat is one fewer session, not an error: enumeration is
|
|
179
|
+
# lazy, so that window spans the whole listing, and agents rotate and
|
|
180
|
+
# compact these logs while a caller is still reading them.
|
|
181
|
+
#
|
|
182
|
+
# The rescue covers the stat and nothing else. Wrapping the hooks too would
|
|
183
|
+
# mean an adapter whose started_at_for raised EACCES silently returned zero
|
|
184
|
+
# sessions — a misdeclared adapter erasing a listing rather than failing.
|
|
185
|
+
# A hook that raises is a programming error and surfaces, matching `all`.
|
|
186
|
+
#
|
|
187
|
+
# SystemCallError, not just ENOENT/EACCES, for the same reason
|
|
188
|
+
# started_at_for's own rescue already widened past those two (see its
|
|
189
|
+
# comment) and read_json's did too: the FILE this stats can itself be a
|
|
190
|
+
# symlink loop (ELOOP) or TCC-denied (EPERM on macOS), not only vanished
|
|
191
|
+
# or permission-denied in the two ways originally listed.
|
|
192
|
+
def build_session(path)
|
|
193
|
+
stat = File.stat(path)
|
|
194
|
+
rescue SystemCallError
|
|
195
|
+
nil
|
|
196
|
+
else
|
|
197
|
+
Session.new(
|
|
198
|
+
agent: self.class.agent_name,
|
|
199
|
+
id: session_id_from(path),
|
|
200
|
+
path: path,
|
|
201
|
+
started_at: started_at_for(path, stat),
|
|
202
|
+
updated_at: updated_at_for(path, stat),
|
|
203
|
+
bytes: bytes_for(path, stat),
|
|
204
|
+
format: primary_layer.format,
|
|
205
|
+
fidelity: self.class.fidelity_value
|
|
206
|
+
) { project_path_for(path) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Streams a JSONL file looking for a record carrying `key`. Bounded twice
|
|
210
|
+
# over — `limit` caps the iterations, MAX_LINE_BYTES caps each read — so the
|
|
211
|
+
# worst case is a few tens of MB even for a file with no newlines in it.
|
|
212
|
+
# Tolerant of the non-JSON lines and non-object records real logs contain:
|
|
213
|
+
# a scan that gives up is worth more here than one that raises.
|
|
214
|
+
#
|
|
215
|
+
# Presence of `key` alone is not "found": a record can carry it with a
|
|
216
|
+
# null or wrong-typed value, which would otherwise stop the scan and
|
|
217
|
+
# permanently shadow a later, usable record — or hand a caller a Hash
|
|
218
|
+
# where it expected a String, which is exactly what turns project_paths'
|
|
219
|
+
# .uniq.sort into an ArgumentError from one malformed record. An optional
|
|
220
|
+
# block is the value check, evaluated only once presence already holds;
|
|
221
|
+
# it defaults to accepting whatever presence accepted, so a caller with
|
|
222
|
+
# no block sees no behavior change.
|
|
223
|
+
def scan_jsonl_for_key(path, key, limit: 25)
|
|
224
|
+
File.foreach(path, "\n", MAX_LINE_BYTES).with_index do |line, index|
|
|
225
|
+
break if index >= limit
|
|
226
|
+
|
|
227
|
+
begin
|
|
228
|
+
record = JSON.parse(line)
|
|
229
|
+
rescue JSON::ParserError, EncodingError
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
next unless record.is_a?(Hash) && record.key?(key)
|
|
233
|
+
next if block_given? && !yield(record)
|
|
234
|
+
|
|
235
|
+
return record
|
|
236
|
+
end
|
|
237
|
+
nil
|
|
238
|
+
# SystemCallError, matching read_json and build_session. The enumerated
|
|
239
|
+
# Errno list this replaced missed EPERM, which is what macOS returns for a
|
|
240
|
+
# TCC-protected path rather than EACCES, and ELOOP. The exposure here is
|
|
241
|
+
# narrower than read_json's — build_session stats the session file first and
|
|
242
|
+
# drops it on any SystemCallError, so a file that fails outright never
|
|
243
|
+
# reaches this — but a file that stats cleanly and then fails on read does,
|
|
244
|
+
# and the three rescues disagreeing on which errnos count is the kind of
|
|
245
|
+
# inconsistency that becomes a bug the moment one of them moves.
|
|
246
|
+
rescue SystemCallError
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
# Gemini CLI. Verified against a real store on this machine (2026-08-24):
|
|
7
|
+
# 9 project directories, 12 chat files, 121 records.
|
|
8
|
+
#
|
|
9
|
+
# The store is keyed by an opaque project hash, not by an encoded path:
|
|
10
|
+
# ~/.gemini/tmp/<projectHash>/chats/session-<UTC stamp>-<hex8>.json, with
|
|
11
|
+
# a sibling logs.json holding a flat prompt log. Nothing anywhere under
|
|
12
|
+
# the store names a working directory — grepping every JSON file in it for
|
|
13
|
+
# cwd, workspace, projectPath, rootPath and directory found zero — which
|
|
14
|
+
# is why project resolution below depends on a separate map file rather
|
|
15
|
+
# than on decoding the hash.
|
|
16
|
+
class Gemini < Base
|
|
17
|
+
agent :gemini
|
|
18
|
+
label "Gemini CLI"
|
|
19
|
+
documented false
|
|
20
|
+
verified_on "2026-08-24"
|
|
21
|
+
fidelity :full
|
|
22
|
+
|
|
23
|
+
def self.reader_class = Readers::Gemini
|
|
24
|
+
|
|
25
|
+
homedir :gemini
|
|
26
|
+
|
|
27
|
+
store :chats, dir: "tmp", glob: "*/chats/session-*.json", format: :json
|
|
28
|
+
store :projects, path: "projects.json", format: :json, optional: true
|
|
29
|
+
|
|
30
|
+
warning "sessions are grouped by an opaque project hash; without ~/.gemini/projects.json " \
|
|
31
|
+
"this gem cannot say which directory a session belongs to"
|
|
32
|
+
|
|
33
|
+
# The delta-log variant: tokentelemetry's parser of this same store
|
|
34
|
+
# handles chats written as JSONL with a header line and `$set` deltas.
|
|
35
|
+
# No such file exists here (zero .jsonl anywhere under the store), so
|
|
36
|
+
# this adapter reads the verified .json spelling only — and says so
|
|
37
|
+
# where a user with the other spelling will see it, rather than
|
|
38
|
+
# silently enumerating nothing for half their sessions.
|
|
39
|
+
def warnings
|
|
40
|
+
list = super
|
|
41
|
+
jsonl = Dir.glob(File.join(escape_glob(primary_layer.path), "*", "chats", "*.jsonl"))
|
|
42
|
+
if jsonl.any?
|
|
43
|
+
list << "#{jsonl.size} chat file(s) use the JSONL delta format, which this adapter does " \
|
|
44
|
+
"not read yet; those sessions are not enumerated"
|
|
45
|
+
end
|
|
46
|
+
list
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# session-2025-11-29T20-08-b20947ab.json. The trailing hex is NOT a
|
|
50
|
+
# session id: two files in the real store share d4abc9ce while being
|
|
51
|
+
# different sessions, so the whole basename is the id — unique, stable,
|
|
52
|
+
# and derivable without opening the file, which is what Layer 2 is for.
|
|
53
|
+
# The agent's own sessionId lives inside the document and reaches a
|
|
54
|
+
# caller through the reader.
|
|
55
|
+
FILENAME = /\Asession-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-\h+\.json\z/
|
|
56
|
+
|
|
57
|
+
# UTC, unlike Codex and pi, whose rollout filenames use the local clock.
|
|
58
|
+
# Verified rather than assumed: four real filenames match their own
|
|
59
|
+
# document's startTime to the minute when read as UTC (12-42 against
|
|
60
|
+
# 2025-12-12T12:42:50.033Z), and would be three hours out as local time
|
|
61
|
+
# on the machine this was written on.
|
|
62
|
+
#
|
|
63
|
+
# Minute precision only — the document's startTime carries seconds, but
|
|
64
|
+
# reading it would cost a parse per session, and Layer 2 is stat-only.
|
|
65
|
+
def started_at_for(path, stat)
|
|
66
|
+
parts = FILENAME.match(File.basename(path))&.captures or return super
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
Time.utc(*parts.map(&:to_i))
|
|
70
|
+
rescue ArgumentError # digits that do not form a real date
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The store groups sessions under a hash of the project directory that
|
|
76
|
+
# this gem cannot reverse: it is not a plain SHA-256 of the path (tested
|
|
77
|
+
# directly against real directories), and the store records the path
|
|
78
|
+
# nowhere else. ~/.gemini/projects.json is the map Gemini itself keeps —
|
|
79
|
+
# when it exists, this reads it; when it does not, nil is the honest
|
|
80
|
+
# answer and `projects` reports nothing rather than inventing a name
|
|
81
|
+
# from the hash.
|
|
82
|
+
def project_path_for(path)
|
|
83
|
+
hash = project_dir_name(path)
|
|
84
|
+
hash && project_map[hash]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# <base>/tmp/<projectHash>/chats/<file>.json — two levels up from the
|
|
88
|
+
# file, not one, so Base's default (the immediate parent) would answer
|
|
89
|
+
# "chats" for every session.
|
|
90
|
+
def project_dir_name(path)
|
|
91
|
+
File.basename(File.dirname(File.dirname(path)))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def encode_project(dir)
|
|
95
|
+
project_map.key(dir) || super
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def project_paths
|
|
99
|
+
return [] unless primary_layer.exists?
|
|
100
|
+
|
|
101
|
+
Dir.glob(File.join(escape_glob(primary_layer.path), "*", "chats", "session-*.json"))
|
|
102
|
+
.filter_map { |path| project_path_for(path) }.uniq.sort
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# projects.json maps a directory to its hash. Memoized per instance, and
|
|
108
|
+
# inverted once here rather than scanned per session — a store with
|
|
109
|
+
# hundreds of sessions would otherwise re-read the file for each.
|
|
110
|
+
#
|
|
111
|
+
# Shape unverified: this file does not exist on the machine this adapter
|
|
112
|
+
# was written on. Both plausible spellings are accepted (a flat
|
|
113
|
+
# path => hash map, and a nested one under "projects"), and anything
|
|
114
|
+
# else yields an empty map, which degrades to the same nil
|
|
115
|
+
# project_path a missing file gives.
|
|
116
|
+
def project_map
|
|
117
|
+
@project_map ||= begin
|
|
118
|
+
layer = layer(:projects)
|
|
119
|
+
raw = layer&.exists? ? read_json(layer.path) : {}
|
|
120
|
+
raw = raw["projects"] if raw["projects"].is_a?(Hash)
|
|
121
|
+
if raw.is_a?(Hash)
|
|
122
|
+
raw.each_with_object({}) do |(dir, hash), map|
|
|
123
|
+
map[hash] = dir if dir.is_a?(String) && hash.is_a?(String)
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
{}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
# Grok Build (xAI). PROVISIONAL: ~/.grok does not exist on the machine
|
|
7
|
+
# this was written on (2026-08-24), so every claim follows
|
|
8
|
+
# tokentelemetry's working parser of the same store
|
|
9
|
+
# (resources/tokentelemetry, _scan_grok_sessions and
|
|
10
|
+
# _grok_usage_from_unified_log) rather than observation.
|
|
11
|
+
#
|
|
12
|
+
# A Grok session is a DIRECTORY, not a file:
|
|
13
|
+
# ~/.grok/sessions/<url-encoded cwd>/<session-uuid>/
|
|
14
|
+
# summary.json chat_history.jsonl events.jsonl updates.jsonl
|
|
15
|
+
# signals.json plan_mode.json subagents/<spawn-id>/meta.json
|
|
16
|
+
#
|
|
17
|
+
# summary.json is what this adapter enumerates, because it is the record
|
|
18
|
+
# that always exists and carries the session's own metadata. The
|
|
19
|
+
# transcript beside it is what the reader reads, and `bytes` counts the
|
|
20
|
+
# whole directory — the same choice Claude's adapter makes for its
|
|
21
|
+
# sidecar tree, and for the same reason: those bytes belong to this
|
|
22
|
+
# session, and a `du` that ignored them would disagree with the disk.
|
|
23
|
+
class Grok < Base
|
|
24
|
+
agent :grok
|
|
25
|
+
label "Grok Build"
|
|
26
|
+
documented false
|
|
27
|
+
verified_on "2026-08-24"
|
|
28
|
+
fidelity :full
|
|
29
|
+
|
|
30
|
+
def self.reader_class = Readers::Grok
|
|
31
|
+
|
|
32
|
+
homedir :grok_build
|
|
33
|
+
|
|
34
|
+
store :sessions, dir: "sessions", glob: "*/*/summary.json", format: :json
|
|
35
|
+
store :unified_log, path: File.join("logs", "unified.jsonl"), format: :jsonl, optional: true
|
|
36
|
+
|
|
37
|
+
warning "billed token usage is not in the session directory: it lives in " \
|
|
38
|
+
"~/.grok/logs/unified.jsonl, keyed by session id, and is unavailable if that " \
|
|
39
|
+
"log has rotated away"
|
|
40
|
+
|
|
41
|
+
def warnings
|
|
42
|
+
list = super
|
|
43
|
+
if primary_layer.exists?
|
|
44
|
+
list << "Grok's store shape is unverified — no ~/.grok existed on the machine this " \
|
|
45
|
+
"adapter was written on, so it follows tokentelemetry's parser of the same " \
|
|
46
|
+
"format. Please open an issue if sessions, projects or usage look wrong."
|
|
47
|
+
end
|
|
48
|
+
list
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# <sessions>/<url-encoded cwd>/<session-uuid>/summary.json — the id is
|
|
52
|
+
# the directory holding the file, not the file's own basename, which is
|
|
53
|
+
# the constant "summary".
|
|
54
|
+
def session_id_from(path)
|
|
55
|
+
File.basename(File.dirname(path))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The project bucket is a URL-encoded absolute path (tokentelemetry
|
|
59
|
+
# unquotes it), so unlike Claude's and pi's dash encodings this one is
|
|
60
|
+
# losslessly reversible. summary.json's own info.cwd is preferred where
|
|
61
|
+
# readable, because a recorded path beats a decoded directory name; the
|
|
62
|
+
# decode is the fallback, and a good one.
|
|
63
|
+
def project_path_for(path)
|
|
64
|
+
recorded = read_json(path).dig("info", "cwd")
|
|
65
|
+
return recorded if recorded.is_a?(String)
|
|
66
|
+
|
|
67
|
+
decode_project(File.basename(File.dirname(File.dirname(path))))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def project_dir_name(path)
|
|
71
|
+
File.basename(File.dirname(File.dirname(path)))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def encode_project(dir)
|
|
75
|
+
# Percent-encode everything a path separator is not, matching what
|
|
76
|
+
# URL-encoding a whole path produces. CGI.escape is deliberately not
|
|
77
|
+
# used: it encodes a space as "+", which decodes back to "+" here.
|
|
78
|
+
URI.encode_www_form_component(dir).gsub("+", "%20")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def decode_project(name)
|
|
82
|
+
URI.decode_www_form_component(name)
|
|
83
|
+
rescue ArgumentError # a name that is not valid percent-encoding
|
|
84
|
+
name
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# summary.json carries the session's own clock; the file's mtime is only
|
|
88
|
+
# ever a proxy for it. Both are ISO 8601 strings per the reference
|
|
89
|
+
# parser, with created_at standing in when updated_at is absent.
|
|
90
|
+
def started_at_for(path, stat)
|
|
91
|
+
parse_time(read_json(path)["created_at"]) || super
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def updated_at_for(path, stat)
|
|
95
|
+
summary = read_json(path)
|
|
96
|
+
parse_time(summary["updated_at"]) || parse_time(summary["created_at"]) || super
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The whole session directory, not just summary.json: the transcript and
|
|
100
|
+
# every sibling log live in it.
|
|
101
|
+
def bytes_for(path, stat)
|
|
102
|
+
dir = File.dirname(path)
|
|
103
|
+
Dir.glob(File.join(escape_glob(dir), "**", "*"), File::FNM_DOTMATCH).sum do |entry|
|
|
104
|
+
File.file?(entry) ? File.size(entry) : 0
|
|
105
|
+
rescue SystemCallError
|
|
106
|
+
0
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def parse_time(value)
|
|
113
|
+
return nil unless value.is_a?(String)
|
|
114
|
+
|
|
115
|
+
Time.iso8601(value)
|
|
116
|
+
rescue ArgumentError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|