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,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Adapters
6
+ class Opencode < Base
7
+ agent :opencode
8
+ label "opencode"
9
+ documented :partly
10
+ verified_on "2026-07-21"
11
+ fidelity :full
12
+
13
+ def self.reader_class = Readers::Opencode
14
+
15
+ homedir :opencode, report_env: ["XDG_DATA_HOME"]
16
+
17
+ store :database, path: "opencode.db", format: :sqlite
18
+ store :legacy, dir: "storage", format: :json, optional: true
19
+
20
+ warning "pre-v1.2.0 storage/ tree may remain on disk after migration; " \
21
+ "counting it alongside opencode.db double-counts sessions"
22
+
23
+ # The declared default stands unless another candidate actually holds a
24
+ # database. Falling back to it rather than to the first candidate that
25
+ # merely exists keeps `where` printing a concrete, conventional path on
26
+ # a machine with no opencode at all.
27
+ def base_dir
28
+ @base_dir ||= begin
29
+ probes = ([resolver.home(:opencode)] + resolver.candidates(:opencode)).map(&:to_s).uniq
30
+ probes.find { |dir| Dir.glob(File.join(escape_glob(dir), DATABASE_GLOB)).any? } || resolver.home(:opencode).to_s
31
+ end
32
+ end
33
+
34
+ # opencode names its database per release channel — opencode.db,
35
+ # opencode-stable.db — so the filename is a glob, not a constant
36
+ # (tokentelemetry globs the same pattern). Unverified here: this machine
37
+ # has only the plain name.
38
+ DATABASE_GLOB = "opencode*.db"
39
+
40
+ SESSION_COLUMNS = "id, directory, time_created, time_updated"
41
+
42
+ # Sessions are rows, not files, so the Base glob enumeration is replaced by
43
+ # a deferred query: it runs at first consumption, and only if the database
44
+ # exists. The existence check comes FIRST so machines without opencode
45
+ # never need sqlite3 at all (design doc section 9).
46
+ #
47
+ # Row order is deliberately unspecified: no ORDER BY, so rows arrive in
48
+ # whatever order SQLite's own scan produces (rowid order, absent an
49
+ # index that would change it) — unlike the other six adapters, which are
50
+ # path-sorted for free by Dir.glob. Invisible today because nothing here
51
+ # sorts before Task 10 does its own sort_by(&:updated_at); stated so a
52
+ # future caller of THIS method directly does not come to depend on
53
+ # insertion order looking stable.
54
+ #
55
+ # The gap between this check and the open below is a real TOCTOU window —
56
+ # opencode could delete or migrate the file in between — but the open
57
+ # that follows a vanished file raises SQLite3::CantOpenException (verified
58
+ # directly), which each_session_row already turns into UnreadableStore.
59
+ # That is judged the right answer, not a bug to special-case: unlike
60
+ # Base's own glob-then-stat race (one file silently missing from a
61
+ # multi-file listing, so build_session's rescue drops it and moves on),
62
+ # a vanished DATABASE is the store's only source for every session, so
63
+ # there is nothing partial to return — "the store I just confirmed
64
+ # exists is now unreadable" is what happened, and UnreadableStore says
65
+ # exactly that.
66
+ #
67
+ # Opens once per consumption, not once per instance: `sessions`,
68
+ # `sessions_for_project` and `project_paths` each open, query and close
69
+ # their own connection through each_session_row. That costs an extra
70
+ # open when a caller uses more than one of the three, but keeps every
71
+ # method independently correct rather than threading a shared handle
72
+ # through them — and Enumerator.new's block does not even run until the
73
+ # RETURNED lazy enumerator is consumed, so a caller that builds `sessions`
74
+ # and never touches it opens nothing at all. Confirmed empirically (not
75
+ # just assumed from Enumerator's docs) that the `ensure db&.close` inside
76
+ # each_session_row fires promptly either way a caller can stop early —
77
+ # `.lazy.first(n)` and an external `each { break }` both unwind the
78
+ # generator fiber immediately, before the outer call returns — so a
79
+ # caller taking `sessions.first` never leaves a connection open waiting
80
+ # for GC to reclaim the fiber.
81
+ def sessions
82
+ paths = database_paths
83
+ return [].lazy if paths.empty?
84
+
85
+ Enumerator.new do |yielder|
86
+ seen = {}
87
+ paths.each do |db_path|
88
+ each_session_row(db_path, "SELECT #{SESSION_COLUMNS} FROM session") do |row|
89
+ # Channel databases can hold the same session — a store migrated
90
+ # between channels keeps both files. Deduped by id, first
91
+ # database wins, so one session is one row to a caller counting
92
+ # them. tokentelemetry dedups the same way for the same reason.
93
+ next if seen[row.first]
94
+
95
+ seen[row.first] = true
96
+ yielder << build_db_session(db_path, row)
97
+ end
98
+ end
99
+ end.lazy
100
+ end
101
+
102
+ # The directory column holds the full recorded path, so filtering is a
103
+ # WHERE clause instead of the Base read-and-compare loop.
104
+ def sessions_for_project(dir)
105
+ dir = File.expand_path(dir)
106
+ paths = database_paths
107
+ return [].lazy if paths.empty?
108
+
109
+ Enumerator.new do |yielder|
110
+ seen = {}
111
+ paths.each do |db_path|
112
+ each_session_row(db_path, "SELECT #{SESSION_COLUMNS} FROM session WHERE directory = ?", [dir]) do |row|
113
+ next if seen[row.first]
114
+
115
+ seen[row.first] = true
116
+ yielder << build_db_session(db_path, row)
117
+ end
118
+ end
119
+ end.lazy
120
+ end
121
+
122
+ # ORDER BY matches the sorted order Base guarantees, so `projects` output is
123
+ # stable and diffable whichever adapter answers it. is_a?(String) excludes
124
+ # a row whose directory is NULL (build_db_session's guard, same rule 2
125
+ # container check) rather than letting a literal nil sort in among real
126
+ # paths — "excluded, not nil", matching Base's project_paths docstring.
127
+ # .uniq is needed on top of SQL's own DISTINCT: SQLite's DISTINCT treats a
128
+ # BLOB and a byte-identical TEXT value as different rows (confirmed
129
+ # directly — typeof reports "text" vs "blob" for the same bytes even
130
+ # though the sqlite3 gem returns both to Ruby as String, see
131
+ # build_db_session's comment), so without this a blob/text pair with
132
+ # identical bytes would surface as two entries where Base's own
133
+ # `.uniq.sort` would collapse them to one.
134
+ def project_paths
135
+ paths = []
136
+ database_paths.each do |db_path|
137
+ each_session_row(db_path, "SELECT DISTINCT directory FROM session ORDER BY directory") do |row|
138
+ paths << row.first if row.first.is_a?(String)
139
+ end
140
+ end
141
+ # Sorted after the union, not per database: SQL's ORDER BY only orders
142
+ # within one file, and two channel databases concatenated would leave
143
+ # `projects` unsorted — the one thing Base guarantees about it.
144
+ paths.uniq.sort
145
+ end
146
+
147
+ # Base checks the declared path literally, which gets a machine holding
148
+ # only a channel-named database wrong twice: its "is this agent
149
+ # installed" gate sees no declared layer and skips the store checks
150
+ # entirely, and the store check itself would report :fail on a real
151
+ # store that is merely called something the declaration did not predict.
152
+ #
153
+ # Any file matching the glob satisfies the claim. The detail names what
154
+ # was actually found, so a non-canonical filename is visible rather than
155
+ # merely tolerated — the same reason detail_for prints a file count.
156
+ def verify
157
+ found = database_paths
158
+ return super if found.empty?
159
+
160
+ checks = super
161
+ database = checks.find { |candidate| candidate.claim == "store database exists" }
162
+ return checks.map { |c| c.claim == database&.claim && !c.pass? ? passing_database(found) : c } if database
163
+
164
+ # The skip gate fired: Base returned one :skip and never looked at the
165
+ # stores. Answer the store claim it never asked.
166
+ [passing_database(found)] + checks.reject { |c| c.claim == "agent is installed" }
167
+ end
168
+
169
+ private
170
+
171
+ def passing_database(found)
172
+ check(:pass, "store database exists", found.join(", "))
173
+ end
174
+
175
+ # Every database in the resolved store directory, canonical name first
176
+ # so its ids win the dedup above. Sorted for a stable order across runs;
177
+ # Dir.glob's own order is filesystem-dependent.
178
+ #
179
+ # The existence check that used to live in each caller is this method
180
+ # returning empty: a machine without opencode never opens anything, and
181
+ # so never needs the sqlite3 gem (design doc section 9).
182
+ def database_paths
183
+ @database_paths ||= begin
184
+ found = Dir.glob(File.join(escape_glob(base_dir), DATABASE_GLOB)).sort
185
+ canonical = primary_layer.path
186
+ found.include?(canonical) ? [canonical] + (found - [canonical]) : found
187
+ end
188
+ end
189
+
190
+ # `directory` carries TEXT affinity, so any numeric literal written to it
191
+ # is converted to text at INSERT time (SQLite's own rule, the mirror image
192
+ # of the INTEGER-affinity coercion `session_time` guards below) — but a
193
+ # NULL that reached this NOT-NULL column the way a NOT-NULL column added
194
+ # via a defaultless ALTER TABLE can hold NULL for pre-existing rows
195
+ # survives affinity untouched, and so does an Integer that landed in a
196
+ # column SQLite gave BLOB (no-conversion) affinity rather than TEXT (the
197
+ # test fixture for this reproduces it — a bare, undeclared column type).
198
+ # is_a?(String) catches both. It does NOT, however, catch an actual BLOB
199
+ # storage class value the way an earlier version of this comment claimed:
200
+ # confirmed directly that the sqlite3 gem returns a BLOB to Ruby as a
201
+ # plain String (ASCII-8BIT-encoded, but still is_a?(String)) — the guard
202
+ # is correct and load-bearing for what it DOES catch (rule 2's container
203
+ # check, the same one Cursor's `cwd.is_a?(String)` applies to the
204
+ # equivalent field, and the "excluded, not nil" project_paths needs per
205
+ # Base's own docstring), just not a universal type filter. project_paths
206
+ # separately guards the BLOB/TEXT duplicate this leaves open.
207
+ def build_db_session(db_path, row)
208
+ id, directory, created_ms, updated_ms = row
209
+ project_path = directory.is_a?(String) ? directory : nil
210
+ Session.new(
211
+ agent: self.class.agent_name, id: id, path: db_path, project_path: project_path,
212
+ started_at: session_time(created_ms),
213
+ updated_at: session_time(updated_ms) || session_time(created_ms) || db_mtime(db_path),
214
+ bytes: nil, # rows in a shared database; a file size would be a lie
215
+ format: primary_layer.format, fidelity: self.class.fidelity_value
216
+ )
217
+ end
218
+
219
+ # Mirrors Cursor's meta_time guard against the identical failure, reached
220
+ # by a different route: time_created/time_updated carry INTEGER affinity,
221
+ # which converts a numeric-LOOKING text value to a number at INSERT time
222
+ # but leaves non-numeric text (or a hand-edited NULL, despite NOT NULL —
223
+ # see build_db_session's comment on the same quirk for `directory`)
224
+ # stored as-is. is_a?(Numeric) rejects that. A value that passes but is
225
+ # merely huge (an absurd but real millisecond count) still overflows to
226
+ # Infinity once forced through Float by `/ 1000.0`, and Time.at(Infinity)
227
+ # raises FloatDomainError uncaught — rule 3's failure mode, one row
228
+ # taking the whole enumeration down. seconds.finite? is checked AFTER the
229
+ # division for the same reason Cursor's is: a finite Integer literal
230
+ # hundreds of digits long still overflows only once divided, so checking
231
+ # millis' own finiteness first would miss it.
232
+ #
233
+ # nil beats a wrong guess here for the same reason Base's started_at_for
234
+ # prefers nil to guessing: the only fallback available for a shared-db
235
+ # row is the database FILE's own stat, which would print the identical
236
+ # timestamp for every session in the store regardless of when each one
237
+ # actually happened — a plausible-looking wrong value is worse than an
238
+ # honest unknown one.
239
+ def session_time(millis)
240
+ return nil unless millis.is_a?(Numeric)
241
+
242
+ seconds = millis / 1000.0
243
+ Time.at(seconds) if seconds.finite?
244
+ end
245
+
246
+ # CRITICAL, caught in review: plan decision 5 makes updated_at a
247
+ # cross-adapter invariant — "never nil; started_at may be" — because
248
+ # every file-based adapter gets it for free from Base#updated_at_for =
249
+ # stat.mtime, which cannot be nil. opencode is the first adapter that
250
+ # CAN return nil here (session_time can fail on both created_ms and
251
+ # updated_ms independently), and a nil is not a value this reader gets
252
+ # to invent locally: Task 9's `since` filter and Task 10's
253
+ # sort_by(&:updated_at) both assume the invariant holds, and one
254
+ # malformed row in a 359-row shared database reaching either would take
255
+ # the WHOLE cross-agent listing down with an ArgumentError or
256
+ # NoMethodError — rule 3's failure mode, relocated one layer up and past
257
+ # every rescue, not removed. build_db_session's fallback chain
258
+ # (updated_ms -> created_ms -> db_mtime) keeps the invariant instead:
259
+ # time_created is a real per-session timestamp, not a guess, so it is
260
+ # tried before falling back to the database FILE's own mtime, which is
261
+ # reached only when a row's own pair of timestamps are BOTH malformed —
262
+ # it is not this session's timestamp, but it is a true upper bound on
263
+ # when anything in the store last changed, and unlike stat.birthtime
264
+ # it is never allowed to be nil either: File.mtime can still raise
265
+ # SystemCallError in the narrow window between a successful query and
266
+ # this call (the same class of race Base's own started_at_for guards
267
+ # its stat against), and letting THAT reach the caller unrescued would
268
+ # reintroduce the exact bug this method exists to close. Time.now is
269
+ # the true last resort — an honest "unknown, treat as just now" — so
270
+ # this method, unlike every other timestamp helper in this file, is
271
+ # never allowed to return nil.
272
+ def db_mtime(db_path)
273
+ @db_mtime ||= begin
274
+ File.mtime(db_path)
275
+ rescue SystemCallError
276
+ Time.now
277
+ end
278
+ end
279
+
280
+ # require_sqlite! must stay OUTSIDE the begin/rescue: if it raised inside,
281
+ # Ruby would evaluate the SQLite3::Exception rescue clause while matching
282
+ # and hit NameError, because the constant was never loaded.
283
+ #
284
+ # The open itself (read-only URI with escaped path, no immutable=1,
285
+ # 5s busy_timeout) lives in Agent::Sessions::Sqlite with the evidence for
286
+ # each choice — extracted when the opencode READER became its second
287
+ # caller. What stays here is this adapter's answer to failure: a query
288
+ # error becomes UnreadableStore, because a vanished or corrupt DATABASE
289
+ # is the store's only source for every session — there is nothing
290
+ # partial to return. Design doc section 10's "never write" caveat also
291
+ # still belongs to this store: opening a WAL db even read-only touches
292
+ # its -shm/-wal sidecars (SQLite's reader bookkeeping, confirmed
293
+ # directly); a directory with no write permission surfaces as
294
+ # SQLite3::ReadOnlyException → UnreadableStore, confirmed against a
295
+ # chmod 0555 directory.
296
+ #
297
+ # Lock contention is NOT covered by a test: reliably reproducing it
298
+ # needs a second process holding a write transaction for the exact
299
+ # duration of the read — noted rather than left looking covered.
300
+ def each_session_row(db_path, sql, params = [], &block)
301
+ require_sqlite!
302
+ db = nil
303
+ begin
304
+ db = Sqlite.open_readonly(db_path)
305
+ db.execute(sql, params, &block)
306
+ rescue SQLite3::Exception => e
307
+ raise UnreadableStore, "#{db_path}: #{e.message}"
308
+ ensure
309
+ db&.close
310
+ end
311
+ end
312
+
313
+ def require_sqlite!
314
+ require "sqlite3"
315
+ rescue LoadError
316
+ raise MissingDependency,
317
+ "opencode sessions live in opencode.db (SQLite); add the sqlite3 gem to enumerate them"
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Adapters
6
+ class Pi < Base
7
+ agent :pi
8
+ label "pi"
9
+ documented true
10
+ verified_on "2026-07-21"
11
+
12
+ fidelity :full
13
+
14
+ # The reader shares this adapter's provisional standing: written against
15
+ # tokentelemetry's parser of the same format, since this machine's pi
16
+ # store holds no session files to observe. Its own header comment says
17
+ # what remains unverified.
18
+ def self.reader_class = Readers::Pi
19
+
20
+ homedir :pi
21
+
22
+ store :sessions, dir: "sessions", glob: "--*--/*.jsonl", format: :jsonl,
23
+ env: "PI_CODING_AGENT_SESSION_DIR"
24
+
25
+ # pi's *session files* are still absent from this machine: all nine
26
+ # directories under ~/.pi/agent/sessions (real pi output — see
27
+ # encode_project below) are empty of .jsonl (2026-08-05). Everything
28
+ # about a session's CONTENT is therefore still inference from design
29
+ # doc 8.6, not observation: the header's "cwd" key (project_path_for
30
+ # below), which line it is on (the limit: argument there), and
31
+ # whether a session's id segment is 8 hex characters or a full uuid
32
+ # (FILENAME below). `warnings` below repeats the gist where a CLI user
33
+ # will actually see it, gated on the store existing.
34
+ #
35
+ # The directory NAMING scheme is a different story: it is real pi
36
+ # output, not a guess — see encode_project's comment.
37
+ #
38
+ # To check the remaining unverified points against a real session:
39
+ # head -1 ~/.pi/agent/sessions/--*--/*.jsonl
40
+ # and look for: which key actually holds the cwd (assumed "cwd"),
41
+ # which line it is on (assumed line 1), and whether the id segment is
42
+ # 8 hex characters or a full uuid (assumed 8 hex). A mismatch means
43
+ # fixing the matching line below and the warning above it, not just
44
+ # the comment next to it.
45
+ def warnings
46
+ list = super
47
+ if primary_layer.exists?
48
+ list << "pi's session header shape is unverified — every project directory under this " \
49
+ "store is empty of .jsonl files on the machine this adapter was written on, so " \
50
+ "the header key inside a real session (assumed \"cwd\") has never been read. If " \
51
+ "`projects` or `du --by project` report nothing while you have sessions, that key " \
52
+ "is not \"cwd\"; please open an issue with the first line of one file."
53
+ end
54
+ list
55
+ end
56
+
57
+ # FILENAME's \h{8} id specifically contradicts the one written source
58
+ # available: design doc section 8.6 says pi's *entries* carry an
59
+ # 8-character hex id, while the section 8 table gives the filename
60
+ # itself as <timestamp>_<uuid>. Both cannot be right, and nothing on
61
+ # this machine can settle which one pi's own encoder does. \h{8} is
62
+ # what is implemented here; if a real file uses a full uuid instead,
63
+ # this regex simply never matches it, and session_id_from below falls
64
+ # back to the basename — a real but visibly non-canonical id, not a
65
+ # crash.
66
+ FILENAME = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})_(\h{8})\.jsonl\z/
67
+
68
+ def session_id_from(path)
69
+ captures = FILENAME.match(File.basename(path))&.captures or return super
70
+
71
+ captures.last
72
+ end
73
+
74
+ # The rescue is not optional. \d{2} accepts 00-99, and Time.new raises
75
+ # ArgumentError on month 13, minute 60 and friends. build_session scopes
76
+ # its own rescue to File.stat so that a raising hook surfaces as the
77
+ # adapter bug it usually is — but this hook raises on FILE DATA, and
78
+ # without the rescue one malformed filename returns zero sessions from
79
+ # `sessions`, `project_paths` and `for_project` alike, and exits the CLI
80
+ # with a raw backtrace that takes every other agent's rows with it.
81
+ # That failure mode was measured in Task 4, against Codex — pi has no
82
+ # real filenames of its own to reproduce it against, but the mechanism
83
+ # (Time.new rejecting digits \d{2} happily accepted) belongs to Ruby,
84
+ # not to any one adapter's data, so the same rescue applies here.
85
+ #
86
+ # Local, not UTC: copied from Codex's VERIFIED behaviour (its rollout
87
+ # filenames use the local clock, confirmed against 360 real files).
88
+ # pi's is UNVERIFIED — no real pi filename exists on this machine to
89
+ # check it against. If pi instead publishes UTC filenames, every pi
90
+ # started_at is silently off by the machine's UTC offset, with no
91
+ # signal that it happened. The test fixture's header timestamp and
92
+ # filename timestamp deliberately disagree (see build_fixture in
93
+ # test/pi_adapter_test.rb) so that a started_at_for which quietly fell
94
+ # back to reading the header would be caught returning the wrong hour,
95
+ # rather than passing by coincidence on a UTC machine.
96
+ #
97
+ # The rescue wraps Time.new alone rather than the whole method. A
98
+ # method-scoped rescue would also swallow an ArgumentError from a
99
+ # future signature change — the commonest Ruby programming error — and
100
+ # silently fall back to stat.birthtime for every pi session: a
101
+ # plausible-looking wrong started_at with no signal, which is worse
102
+ # than a crash.
103
+ def started_at_for(path, stat)
104
+ parts = FILENAME.match(File.basename(path))&.captures or return super
105
+
106
+ begin
107
+ Time.new(*parts.first(6).map(&:to_i))
108
+ rescue ArgumentError # the digits matched but do not form a real date
109
+ super
110
+ end
111
+ end
112
+
113
+ # Verified against nine real pi project directories found on this
114
+ # machine on 2026-08-05 (~/.pi/agent/sessions/--*--, empty of .jsonl
115
+ # but real encoder output regardless — see the class comment above)
116
+ # — see test_encode_project_round_trips_the_nine_real_pi_directories
117
+ # in test/pi_adapter_test.rb. Design doc section 7 described this only
118
+ # as "wrap the dashed cwd in double dashes," ambiguous on two points
119
+ # neither of us had settled by observation. Both are now settled by
120
+ # real output rather than by carrying Claude's rule over into pi's:
121
+ #
122
+ # 1. The dash count. Read literally — dash-encode the WHOLE cwd,
123
+ # including its leading "/", then wrap that in "--" — an
124
+ # absolute path would get THREE leading dashes. Real pi output
125
+ # has TWO: the leading "/" is absorbed into the wrap rather than
126
+ # separately encoded, matching the store's own "--*--/*.jsonl"
127
+ # glob (above).
128
+ #
129
+ # 2. The character class. This used to read like Claude's "every
130
+ # non-alphanumeric character becomes -" and that was WRONG: pi
131
+ # preserves dots. Two of the nine real directories contain a
132
+ # literal "." (a domain name in the path) unchanged, while the
133
+ # "/" separators around it became "-". Claude has 45 project
134
+ # directories on this same machine and not one contains a dot —
135
+ # the two adapters' rules genuinely differ; they do not merely
136
+ # happen to agree on every example seen before now.
137
+ #
138
+ # What remains a guess: "_", spaces, and any other non-"/" separator
139
+ # never appear in the nine real directories, so nothing here confirms
140
+ # whether pi encodes them or preserves them too, the way it preserves
141
+ # ".". Do not widen this gsub back into a character class without new
142
+ # evidence — that is exactly the mistake being corrected here.
143
+ #
144
+ # This directory-name encoding is what sessions_for_project falls back
145
+ # to when a session's own header cwd cannot be read (see
146
+ # Base#encode_project) — pi's whole safety net for project_path_for's
147
+ # still-unverified "cwd" header key assumption. That fallback is now
148
+ # solid: a wrong "cwd" key degrades to accurate name matching instead
149
+ # of two guesses compounding into silent failure.
150
+ #
151
+ # Expects an absolute, expanded path; sessions_for_project expands
152
+ # first.
153
+ def encode_project(dir)
154
+ "--#{dir.delete_prefix("/").gsub("/", "-")}--"
155
+ end
156
+
157
+ # pi publishes its format: one header line, then typed entries (design
158
+ # doc 8.6) — which argues for staying TIGHTER than Claude's 25, whose
159
+ # cwd genuinely was not on line 1 and whose format was never
160
+ # published. But "publishes a spec" is not the same evidence as
161
+ # "measured against a real file," and this machine has zero pi
162
+ # sessions to measure against. limit: 25 matches Claude's number not
163
+ # because pi is assumed to behave like Claude, but because
164
+ # scan_jsonl_for_key returns as soon as it finds a usable record: the
165
+ # width costs nothing while the line-1 assumption holds, and is only
166
+ # ever paid on the one case this file cannot rule out — a preamble pi
167
+ # does not document, the same way Claude's kebab-case preamble was not
168
+ # documented either. The one real cost of going wide: the predicate
169
+ # below is type-checked but not otherwise selective, so a longer
170
+ # window is more exposure to a later, unrelated record that happens to
171
+ # carry a String "cwd" of its own — a decoy shadowing pi's real one —
172
+ # a risk this file cannot bound without a real session to look at.
173
+ #
174
+ # The predicate is mandatory, not decoration. scan_jsonl_for_key stops
175
+ # at the first record merely CARRYING the key, so without a value
176
+ # guard a record holding "cwd": null shadows a later usable one
177
+ # permanently, and a non-String cwd reaches project_paths' .uniq.sort
178
+ # and raises.
179
+ def project_path_for(path)
180
+ scan_jsonl_for_key(path, "cwd", limit: 25) { |record| record["cwd"].is_a?(String) }&.fetch("cwd")
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Adapters
6
+ # Qwen Code. PROVISIONAL: ~/.qwen does not exist on the machine this was
7
+ # written on (2026-08-24), so every claim here comes from tokentelemetry's
8
+ # working parser of the same store (resources/tokentelemetry,
9
+ # backend/main.py section 4) rather than from observation — the same
10
+ # standing the pi reader carries, and declared the same way.
11
+ #
12
+ # Qwen is a Gemini CLI fork that kept Gemini's directory layout and
13
+ # adopted Anthropic's message shape, which is why its store looks like
14
+ # ~/.gemini's while its records read like Claude's.
15
+ class Qwen < Base
16
+ agent :qwen
17
+ label "Qwen Code"
18
+ documented false
19
+ verified_on "2026-08-24"
20
+ fidelity :full
21
+
22
+ def self.reader_class = Readers::Qwen
23
+
24
+ homedir :qwen
25
+
26
+ store :chats, dir: "projects", glob: "*/chats/*.jsonl", format: :jsonl
27
+ store :settings, path: "settings.json", format: :json, optional: true
28
+
29
+ def warnings
30
+ list = super
31
+ if primary_layer.exists?
32
+ list << "Qwen's store shape is unverified — no ~/.qwen existed on the machine this " \
33
+ "adapter was written on, so it follows tokentelemetry's parser of the same " \
34
+ "format. If sessions or projects look wrong, please open an issue with the " \
35
+ "first line of one chat file."
36
+ end
37
+ list
38
+ end
39
+
40
+ # Unlike Gemini's, the project directory is reported as a name this gem
41
+ # cannot decode into a path, so the recorded cwd inside the file is the
42
+ # only source — the same position Claude and pi are in. The predicate is
43
+ # mandatory: scan_jsonl_for_key stops at the first record merely CARRYING
44
+ # the key, so without it a record holding "cwd": null shadows a later
45
+ # usable one permanently.
46
+ def project_path_for(path)
47
+ scan_jsonl_for_key(path, "cwd", limit: 25) { |record| record["cwd"].is_a?(String) }&.fetch("cwd")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # Answers: are these plaintext transcripts inside anything that syncs?
6
+ # Needs only Layer 1. Time Machine exclusion status is a planned addition.
7
+ class Audit
8
+ include HomeExpansion
9
+
10
+ Finding = Data.define(:agent, :kind, :path, :bytes, :synced_to)
11
+
12
+ SYNC_ROOTS = {
13
+ dropbox: ["~/Dropbox"],
14
+ icloud: ["~/Library/Mobile Documents"],
15
+ cloud_storage: ["~/Library/CloudStorage"],
16
+ onedrive: ["~/OneDrive"],
17
+ google_drive: ["~/Google Drive"]
18
+ }.freeze
19
+
20
+ def initialize(stores, env: ENV)
21
+ @stores = stores
22
+ @env = env
23
+ end
24
+
25
+ def report
26
+ @stores.flat_map do |store|
27
+ store.layers.select(&:exists?).map do |location|
28
+ Finding.new(
29
+ agent: store.agent,
30
+ kind: location.kind,
31
+ path: location.path,
32
+ bytes: bytes_under(location.path),
33
+ synced_to: sync_services_for(location.path)
34
+ )
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def bytes_under(path)
42
+ return File.size(path) if File.file?(path)
43
+
44
+ # FNM_DOTMATCH because a plain **/* skips dotfiles, and a byte total that
45
+ # quietly omits them is worse than no total at all.
46
+ Dir.glob(File.join(path, "**", "*"), File::FNM_DOTMATCH).sum do |entry|
47
+ File.file?(entry) ? File.size(entry) : 0
48
+ end
49
+ end
50
+
51
+ # Both sides are resolved through realpath before comparing. On macOS a
52
+ # temp dir lives at /private/var/... behind a /var symlink, and ~/Dropbox
53
+ # is frequently a symlink itself, so comparing raw paths silently misses.
54
+ def sync_services_for(path)
55
+ real = real_path(path) || path
56
+ SYNC_ROOTS.select do |_service, roots|
57
+ roots.any? do |root|
58
+ expanded = real_path(expand(root))
59
+ expanded && (real == expanded || real.start_with?("#{expanded}/"))
60
+ end
61
+ end.keys
62
+ end
63
+
64
+ def real_path(path)
65
+ File.realpath(path)
66
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ Check = Data.define(:agent, :status, :claim, :detail) do
6
+ def pass? = status == :pass
7
+ end
8
+ end
9
+ end