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