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,532 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "time"
5
+
6
+ module Agent
7
+ module Sessions
8
+ class CLI
9
+ STATUS_MARKS = { pass: "✓", fail: "✗", drift: "~", skip: "-" }.freeze
10
+
11
+ def initialize(argv, env: ENV, stdout: $stdout, stderr: $stderr, now: Time.now)
12
+ @argv = argv.dup
13
+ @env = env
14
+ @stdout = stdout
15
+ @stderr = stderr
16
+ @now = now
17
+ @skipped_agents = []
18
+ end
19
+
20
+ def run
21
+ command = @argv.shift
22
+ case command
23
+ when "where" then where
24
+ when "list" then list
25
+ when "du" then du
26
+ when "doctor" then doctor
27
+ when "audit" then audit
28
+ when "version", "--version", "-v" then version
29
+ when nil, "help", "--help", "-h" then help(@stdout, 0)
30
+ else
31
+ @stderr.puts "unknown command: #{command}"
32
+ help(@stderr, 1)
33
+ end
34
+ # Catches UnknownAgent for a typo'd agent, the declaration errors an
35
+ # adapter raises when it is misconfigured, and — since Task 8 —
36
+ # MissingDependency (opencode's sqlite3 gem missing) and UnreadableStore
37
+ # (opencode's database present but unreadable), both Agent::Sessions::Error
38
+ # subclasses. "Layer 1 never raises for disk state" was true before
39
+ # Layer 2 existed; it is not anymore — opencode's Layer 2 raises both
40
+ # deliberately (design doc section 9). `list` (Task 10) is the first
41
+ # command that walks Layer 2, and it does NOT rely on this rescue for
42
+ # that case — collect_sessions catches both per agent so one bad store
43
+ # never empties the rest of the listing (decision 11). This broad catch
44
+ # still matters for `list`'s own option parsing (a malformed --since is
45
+ # an Agent::Sessions::Error) and stays here as the backstop for whichever
46
+ # future command calls into Layer 2 without its own per-agent rescue.
47
+ rescue Agent::Sessions::Error, Agent::Homedir::Error, OptionParser::ParseError => e
48
+ @stderr.puts e.message
49
+ 1
50
+ end
51
+
52
+ private
53
+
54
+ def where
55
+ json = parse_json_flag("where [AGENT] [--json]")
56
+ agent = @argv.shift&.to_sym
57
+ stores = agent ? [Agent::Sessions.locate(agent, env: @env)] : Agent::Sessions.all(env: @env)
58
+ if json
59
+ emit_json(stores)
60
+ else
61
+ stores.each { |store| print_store(store) }
62
+ end
63
+ 0
64
+ end
65
+
66
+ def doctor
67
+ json = parse_json_flag("doctor [AGENT] [--json]")
68
+ agent = @argv.shift&.to_sym
69
+ checks = Agent::Sessions.doctor(agent, env: @env)
70
+ if json
71
+ emit_json(checks)
72
+ else
73
+ # doctor returns every agent's store checks followed by every agent's
74
+ # staleness check, which for seven agents scatters one agent's answer
75
+ # across two distant regions of the terminal. Group for reading; the
76
+ # flat array stays as-is for --json consumers.
77
+ checks.group_by(&:agent).each do |agent_name, agent_checks|
78
+ @stdout.puts agent_name
79
+ agent_checks.each do |check|
80
+ @stdout.puts " #{STATUS_MARKS.fetch(check.status)} #{check.claim}: #{check.detail}"
81
+ end
82
+ end
83
+ end
84
+ checks.any? { |c| c.status == :fail } ? 1 : 0
85
+ end
86
+
87
+ def list
88
+ options = { json: false, agent: nil, project: nil, since: nil }
89
+ OptionParser.new do |opts|
90
+ opts.banner = "Usage: agent-sessions list [--agent X] [--project DIR] [--since 30d] [--json]"
91
+ opts.on("--agent NAME", "Only this agent") { |value| options[:agent] = value.to_sym }
92
+ opts.on("--project DIR", "Only sessions recorded in DIR") { |value| options[:project] = value }
93
+ opts.on("--since DURATION", "Only sessions updated within DURATION (12h, 30d, 2w)") do |value|
94
+ options[:since] = parse_since(value)
95
+ end
96
+ opts.on("--json", "Output JSON") { options[:json] = true }
97
+ end.permute!(@argv)
98
+ reject_positional_args!("list")
99
+
100
+ rows = collect_sessions(options).sort_by(&:updated_at).reverse
101
+ if options[:json]
102
+ @stdout.puts JSON.pretty_generate(rows.map { |session| jsonable(session_row(session)) })
103
+ else
104
+ print_session_table(rows)
105
+ end
106
+ warn_unresolved_projects(options[:agent]) if options[:project]
107
+ exit_code_honoring_skips
108
+ end
109
+
110
+ def du
111
+ options = { json: false, by: "agent" }
112
+ OptionParser.new do |opts|
113
+ opts.banner = "Usage: agent-sessions du [--by agent|project] [--json]"
114
+ opts.on("--by KIND", "Group by agent (default) or project") do |value|
115
+ raise Error, "invalid --by #{value.inspect} (use agent or project)" unless %w[agent project].include?(value)
116
+
117
+ options[:by] = value
118
+ end
119
+ opts.on("--json", "Output JSON") { options[:json] = true }
120
+ end.permute!(@argv)
121
+ reject_positional_args!("du")
122
+
123
+ sessions = collect_sessions({})
124
+ warn_zero_row_gated_agents(sessions)
125
+
126
+ groups = if options[:by] == "project"
127
+ # The opt-in that pays for project reads: one bounded read per
128
+ # file-based session (decision 12 — plain `list` never pays
129
+ # this cost). opencode alone pays nothing extra here, since
130
+ # its project_path answers from a column its query already
131
+ # selected. A session whose project cannot be resolved groups
132
+ # under "(unknown)" rather than being dropped — three of
133
+ # seven adapters can legitimately return nil (Amp with no
134
+ # workspace tree, cursor_ide by design, pi if its unverified
135
+ # header assumption is wrong).
136
+ sessions.group_by { |session| session.project_path || "(unknown)" }
137
+ else
138
+ sessions.group_by { |session| session.agent.to_s }
139
+ end
140
+
141
+ # known_bytes descending, count descending as the tiebreaker. Without
142
+ # the second key, every all-unknown group (known_bytes 0 — opencode's
143
+ # 359 real sessions on this machine, entirely nil bytes) ties with any
144
+ # other all-unknown group and falls back to group_by's insertion order,
145
+ # which is registration order, not anything about the data. The
146
+ # tiebreaker at least puts the biggest all-unknown group first among
147
+ # its unknown peers, rather than leaving it to accident.
148
+ rows = groups.map { |name, group| du_row(name, group) }
149
+ .sort_by { |row| [-row.fetch(:known_bytes), -row.fetch(:count)] }
150
+ if options[:json]
151
+ payload = rows.map do |row|
152
+ { group: row[:group], sessions: row[:count],
153
+ bytes: row[:unknown] == row[:count] ? nil : row[:known_bytes],
154
+ unknown_sessions: row[:unknown] }
155
+ end
156
+ # Every other JSON-emitting command (list, where, doctor, audit)
157
+ # funnels through jsonable; this one is `group:`, a recorded cwd
158
+ # under --by project, was going straight to JSON.pretty_generate and
159
+ # crashing on the first invalid-UTF-8 path. jsonable's Hash branch is
160
+ # transform_values, so this covers group: (and sessions/bytes/
161
+ # unknown_sessions, unaffected since they are not Strings) the same
162
+ # way emit_json covers every other command's payload.
163
+ @stdout.puts JSON.pretty_generate(payload.map { |row| jsonable(row) })
164
+ else
165
+ print_du_table(rows, sessions)
166
+ end
167
+ exit_code_honoring_skips
168
+ end
169
+
170
+ def audit
171
+ json = parse_json_flag("audit [--json]")
172
+ findings = Agent::Sessions.audit(env: @env)
173
+ if json
174
+ emit_json(findings)
175
+ else
176
+ print_audit(findings)
177
+ end
178
+ 0
179
+ end
180
+
181
+ def version
182
+ @stdout.puts Agent::Sessions::VERSION
183
+ 0
184
+ end
185
+
186
+ def help(io, status)
187
+ io.puts <<~USAGE
188
+ Usage: agent-sessions COMMAND [options]
189
+
190
+ Commands:
191
+ where [AGENT] resolved paths, env overrides, format, retention
192
+ list sessions, newest first (--agent, --project, --since)
193
+ du session disk usage (--by agent|project)
194
+ doctor [AGENT] verify on-disk layout against the adapter's claims
195
+ audit bytes per store and sync/backup exposure
196
+ version print version
197
+
198
+ Options:
199
+ --json machine-readable output (where, list, du, doctor, audit)
200
+ USAGE
201
+ status
202
+ end
203
+
204
+ def parse_json_flag(banner)
205
+ json = false
206
+ OptionParser.new do |opts|
207
+ opts.banner = "Usage: agent-sessions #{banner}"
208
+ opts.on("--json", "Output JSON") { json = true }
209
+ end.permute!(@argv)
210
+ json
211
+ end
212
+
213
+ def print_store(store)
214
+ installed = store.installed? ? "" : " (not installed)"
215
+ @stdout.puts "#{store.label}#{installed}"
216
+ store.layers.each do |location|
217
+ @stdout.puts " #{location.kind}: #{location.path} [#{location.format}]"
218
+ end
219
+ store.env_overrides.each do |override|
220
+ state = override.active? ? "= #{override.value}" : "(not set)"
221
+ @stdout.puts " env: #{override.name} #{state}"
222
+ end
223
+ @stdout.puts " retention: #{store.retention ? "#{store.retention} days" : "none"}"
224
+ store.warnings.each { |warning| @stdout.puts " warning: #{warning}" }
225
+ @stdout.puts
226
+ end
227
+
228
+ def print_audit(findings)
229
+ rows = findings.map { |finding| ["#{finding.agent}/#{finding.kind}", human_bytes(finding.bytes), finding] }
230
+ label_width = rows.map { |label, _, _| label.length }.max || 0
231
+ size_width = rows.map { |_, size, _| size.length }.max || 0
232
+
233
+ rows.each do |label, size, finding|
234
+ risk = finding.synced_to.any? ? " SYNCED: #{finding.synced_to.join(", ")}" : ""
235
+ @stdout.puts "#{label.ljust(label_width)} #{size.rjust(size_width)} #{finding.path}#{risk}"
236
+ end
237
+
238
+ at_risk = findings.select { |f| f.synced_to.any? }.sum(&:bytes)
239
+ @stdout.puts "#{human_bytes(at_risk)} in synced locations"
240
+ end
241
+
242
+ def emit_json(records)
243
+ @stdout.puts JSON.pretty_generate(records.map { |record| jsonable(record.to_h) })
244
+ end
245
+
246
+ def jsonable(value)
247
+ case value
248
+ when Hash then value.transform_values { |v| jsonable(v) }
249
+ when Array then value.map { |v| jsonable(v) }
250
+ when Data then jsonable(value.to_h)
251
+ when Date then value.iso8601
252
+ when Time then value.iso8601
253
+ # JSON.parse happily hands back a String carrying invalid UTF-8 (a raw
254
+ # \xFF in a session log, say), and JSON.generate then raises
255
+ # JSON::GeneratorError on it — not an Agent::Sessions::Error, so it
256
+ # escapes `run`'s rescue and takes the whole command down with a raw
257
+ # backtrace over one malformed file. du --by project is the first path
258
+ # that puts a recorded cwd straight into a JSON value (list omits
259
+ # project_path entirely — decision 12), which is what makes this
260
+ # reachable today. A no-op for the well-formed data every other value
261
+ # here already is.
262
+ when String then value.scrub("?")
263
+ else value
264
+ end
265
+ end
266
+
267
+ SINCE_UNITS = { "h" => 3600, "d" => 86_400, "w" => 604_800 }.freeze
268
+
269
+ # One agent's missing dependency or unreadable store must not silently empty
270
+ # a cross-agent listing — each skip is announced on stderr, tracked in
271
+ # @skipped_agents so the command can exit non-zero, and the rest still
272
+ # print. The exit code matters as much as the stderr line: `--json` is the
273
+ # door built for a machine consumer (design doc section 12), and a machine
274
+ # reading `[]` next to exit 0 has no way to tell "empty store" from
275
+ # "store I couldn't read" — the skip notice lives on stderr, which a
276
+ # machine consumer of stdout JSON has every reason to discard. Decision 11
277
+ # calls silent under-reporting this gem's worst failure mode; a truthful
278
+ # message nobody who needs it ever sees is a milder version of the same
279
+ # failure. UnreadableStore became reachable here in Task 8: opencode
280
+ # raises it for a corrupt database, a non-writable store directory, or a
281
+ # writer stuck past busy_timeout, and without this clause one of those
282
+ # costs the user the other six agents' rows.
283
+ #
284
+ # `list` always sorts newest-first, so the whole matching set must be
285
+ # materialized before anything can print, no matter how lazy the pipeline
286
+ # underneath is — sorting is what forces that, not .force. The separate,
287
+ # real cost is STAT COUNT, not laziness: for the six file-based adapters,
288
+ # even a narrow --since window still stats every file in the store,
289
+ # because updated_at can only be learned by stating it (nothing here
290
+ # pushes the window into the adapter, though Codex's date-partitioned
291
+ # directories are a structural hint that could). opencode is the
292
+ # exception, and in the OTHER direction from what an early draft of this
293
+ # comment claimed: it never stats a file — it answers from a SQL query —
294
+ # so it pays no per-session stat cost regardless of --since; `since:`
295
+ # below just filters whatever the query already returned, in Ruby, not in
296
+ # a WHERE clause. Measured against this machine's real stores: the full
297
+ # `list` sweep across all seven agents (~900 real sessions) is 0.021s, so
298
+ # none of this is worth adapter-level plumbing yet. Revisit if a store
299
+ # grows enough to change that.
300
+ #
301
+ # The non-project path delegates to Agent::Sessions.sessions(since:), which
302
+ # already implements and documents this exact >=-inclusive comparison —
303
+ # duplicating it here would let the two drift. The --project path can't
304
+ # reuse it (for_project takes no since:), so it filters inline; both
305
+ # express the identical comparison, just through different plumbing.
306
+ #
307
+ # Plan follow-up 9 ("a one-line stderr note when a gated-warning agent
308
+ # contributes zero rows would put the message where the symptom is —
309
+ # decide in Task 10") is considered here and deferred to Task 11's `du`.
310
+ # Six of seven adapters' warnings name the symptom as "projects or
311
+ # du --by project report nothing", which is du's territory, not list's.
312
+ # Surfacing it also needs a fact list doesn't otherwise fetch — whether
313
+ # the store is INSTALLED at all (Store#installed?, via locate()) versus
314
+ # installed-but-warned-and-genuinely-empty, since zero rows from an agent
315
+ # nobody has ever used is not a symptom worth a line. Doing that lookup
316
+ # here would mean doing it again once Task 11 lands its own version.
317
+ def collect_sessions(options)
318
+ agents = options[:agent] ? [options[:agent]] : Agent::Sessions.agents
319
+ agents.flat_map do |agent|
320
+ scoped = if options[:project]
321
+ sessions = Agent::Sessions.for_project(options[:project], env: @env, agents: [agent])
322
+ options[:since] ? sessions.select { |session| session.updated_at >= options[:since] } : sessions
323
+ else
324
+ Agent::Sessions.sessions(agent, env: @env, since: options[:since])
325
+ end
326
+ scoped.force
327
+ rescue MissingDependency, UnreadableStore => e
328
+ @skipped_agents << agent
329
+ @stderr.puts "#{agent}: skipped (#{e.message})"
330
+ []
331
+ end
332
+ end
333
+
334
+ # Shared by list and du (Task 11), both of which call collect_sessions:
335
+ # a skip must flip the exit code even though the rest of the output still
336
+ # printed successfully. One list, not a list plus a boolean that mirrors
337
+ # it: two variables recording the same fact (an earlier draft had
338
+ # @agents_skipped alongside @skipped_agents) are one rename away from
339
+ # silently disagreeing, which is exactly the failure mode decision 11a
340
+ # exists to prevent.
341
+ def exit_code_honoring_skips
342
+ @skipped_agents.empty? ? 0 : 1
343
+ end
344
+
345
+ # `list claude` looks like it worked: it silently lists every agent's
346
+ # sessions instead of erroring on the typo, because list takes no bare
347
+ # positional (decision 10 — three filters need names) while where and
348
+ # doctor take exactly one. That similarity is what makes the typo
349
+ # tempting to type. du (Task 11) takes the same flags-only shape, so this
350
+ # check is shared rather than inlined into list alone.
351
+ def reject_positional_args!(command)
352
+ return if @argv.empty?
353
+
354
+ raise Error, "#{command}: unexpected argument #{@argv.first.inspect} (this command takes flags only)"
355
+ end
356
+
357
+ def parse_since(value)
358
+ match = /\A(\d+)([hdw])\z/.match(value)
359
+ raise Error, "invalid --since #{value.inspect} (use forms like 12h, 30d, 2w)" unless match
360
+
361
+ @now - (match[1].to_i * SINCE_UNITS.fetch(match[2]))
362
+ end
363
+
364
+ # No project_path column: emitting it would force a content read per row,
365
+ # turning a stat-only listing into a full sweep. du --by project opts in.
366
+ def session_row(session)
367
+ {
368
+ agent: session.agent, id: session.id, uid: session.uid, path: session.path,
369
+ started_at: session.started_at, updated_at: session.updated_at,
370
+ bytes: session.bytes, format: session.format, fidelity: session.fidelity
371
+ }
372
+ end
373
+
374
+ # Cap the id column. Cursor's ids are two nested uuids joined by "/" (36 +
375
+ # 1 + 36 = 73 chars) where every other agent needs a bare uuid (36) or
376
+ # less, so one Cursor row makes the global id_width 73 — padding every
377
+ # other row with ~35 spaces and pushing the line past 100 chars, which
378
+ # wraps on an 80-column terminal. Elide the middle and keep both ends,
379
+ # since the ends are what a human matches against a directory name.
380
+ ID_COLUMN_MAX = 38
381
+
382
+ # Group names in `du --by project` are the same shape of problem one cap
383
+ # wider: a real project path on this machine ran 169 characters, wrapping
384
+ # every row across three lines on an 80-column terminal (list's own id
385
+ # column tops out at 72 total). Wider than ID_COLUMN_MAX because a path's
386
+ # head (which user, which drive) and tail (the actual project directory)
387
+ # are both worth keeping, and both need more room than a bare uuid does.
388
+ GROUP_COLUMN_MAX = 60
389
+
390
+ def elide(text, max = ID_COLUMN_MAX)
391
+ return text if text.length <= max
392
+
393
+ keep = (max - 1) / 2
394
+ "#{text[0, keep]}…#{text[-keep..]}"
395
+ end
396
+
397
+ def print_session_table(rows)
398
+ return if rows.empty?
399
+
400
+ agent_width = rows.map { |session| session.agent.to_s.length }.max
401
+ id_width = rows.map { |session| elide(session.id).length }.max
402
+ size_cells = rows.map { |session| bytes_cell(session.bytes) }
403
+ size_width = size_cells.map(&:length).max
404
+ rows.each_with_index do |session, index|
405
+ @stdout.puts [
406
+ session.agent.to_s.ljust(agent_width),
407
+ elide(session.id).ljust(id_width),
408
+ session.updated_at.strftime("%Y-%m-%d %H:%M"),
409
+ size_cells[index].rjust(size_width)
410
+ ].join(" ")
411
+ end
412
+ end
413
+
414
+ # opencode sessions are rows in a shared database, not standalone files, so
415
+ # their size is not a file size and nil means unknown, not zero.
416
+ def bytes_cell(bytes)
417
+ bytes.nil? ? "?" : human_bytes(bytes)
418
+ end
419
+
420
+ def du_row(name, group)
421
+ { group: name, count: group.size,
422
+ known_bytes: group.sum { |session| session.bytes || 0 },
423
+ unknown: group.count { |session| session.bytes.nil? } }
424
+ end
425
+
426
+ def print_du_table(rows, sessions)
427
+ return if rows.empty?
428
+
429
+ all_rows = rows + [du_row("TOTAL", sessions)]
430
+ # Elided for display only — the underlying row[:group] (and the JSON
431
+ # payload built from the same rows) keeps the full, unelided path.
432
+ display_names = all_rows.map { |row| elide(row[:group], GROUP_COLUMN_MAX) }
433
+ name_width = display_names.map(&:length).max
434
+ count_width = all_rows.map { |row| row[:count].to_s.length }.max
435
+ size_cells = all_rows.map { |row| du_bytes_cell(row) }
436
+ size_width = size_cells.map(&:length).max
437
+ all_rows.each_with_index do |row, index|
438
+ @stdout.puts [
439
+ display_names[index].ljust(name_width),
440
+ row[:count].to_s.rjust(count_width),
441
+ size_cells[index].rjust(size_width)
442
+ ].join(" ")
443
+ end
444
+ end
445
+
446
+ # All sizes in the group unknown -> "?" (never a silently-short zero, per
447
+ # decision 7 — opencode's bytes are nil for all 359 real sessions on this
448
+ # machine, and a bare "0 B" would look like a real, tiny answer instead of
449
+ # "cannot know"). Some unknown -> a trailing "+" on the known total, since
450
+ # it is real but incomplete. All known -> the plain number.
451
+ #
452
+ # The "+" says "incomplete" but not "by how much" — deliberate. The text
453
+ # table stays a one-glance summary; a consumer that needs the exact gap
454
+ # already has --json, whose payload carries unknown_sessions per row.
455
+ #
456
+ # The plan's own sketch of this guarded the "?" branch with
457
+ # row[:count].positive? too. Dropped here, disclosed rather than silently
458
+ # omitted: du_row's `count` is a group's own group_by size, which
459
+ # group_by never returns as zero, so that guard cannot be false in
460
+ # practice. Kept out rather than kept "just in case" — a condition
461
+ # nothing can make false is a claim about a guarantee elsewhere, not a
462
+ # check this method needs to make itself.
463
+ def du_bytes_cell(row)
464
+ return "?" if row[:unknown] == row[:count]
465
+
466
+ cell = human_bytes(row[:known_bytes])
467
+ row[:unknown].positive? ? "#{cell}+" : cell
468
+ end
469
+
470
+ # Plan follow-up 9, decided here rather than left open a second time (the
471
+ # Task 10 review deferred it to du's territory: every gated warning across
472
+ # pi/amp/cursor/cursor_ide names its own symptom as "projects or
473
+ # du --by project report nothing", which is this command, not list's).
474
+ # Fires only for an agent whose store is actually installed
475
+ # (Store#installed?) and carries at least one warning — a never-used
476
+ # agent (the common case for pi/cursor/cursor_ide on most machines) stays
477
+ # silent, since zero rows from an agent nobody has ever used is not a
478
+ # symptom worth a line. Also skips any agent collect_sessions already
479
+ # reported skipped above, whose stderr line already explains the zero
480
+ # rows for a different, already-visible reason.
481
+ #
482
+ # Deliberately does NOT flip the exit code the way a skip does (decision
483
+ # 11a): a skip means the printed total is silently WRONG (an agent's
484
+ # bytes are simply missing from it), which is the failure decision 11a
485
+ # exists to catch. A warned-but-empty agent's total is still RIGHT — it
486
+ # correctly reports zero for that agent — merely unexplained without this
487
+ # line. Exit 0 says the numbers are trustworthy; the stderr line is a
488
+ # pointer to more context, not a correction to them.
489
+ def warn_zero_row_gated_agents(sessions)
490
+ reporting = sessions.map(&:agent).uniq
491
+ (Agent::Sessions.agents - @skipped_agents - reporting).each do |agent|
492
+ store = Agent::Sessions.locate(agent, env: @env)
493
+ next unless store.installed? && !store.warnings.empty?
494
+
495
+ @stderr.puts "#{agent}: installed but contributed 0 sessions here — " \
496
+ "run `agent-sessions where #{agent}` to see why (#{store.warnings.size} warning(s))"
497
+ end
498
+ end
499
+
500
+ # Plan §Step 3a: `project_paths`/`sessions_for_project` both exclude a
501
+ # session whose project could not be resolved rather than counting it,
502
+ # so an agent that genuinely records no projects here and one whose
503
+ # resolution is silently broken look identical. Gated on --project, the
504
+ # one `list` mode that already pays a per-session content read
505
+ # (decision 12) — a bare `list` never touches project_path and must not
506
+ # start paying for it just to print this note. Skips any agent already
507
+ # reported skipped above (its stderr line already explains itself) and
508
+ # tolerates a fresh MissingDependency/UnreadableStore the same way
509
+ # (state can change between the two reads in principle, however
510
+ # unlikely in practice) rather than letting this diagnostic take the
511
+ # command down.
512
+ def warn_unresolved_projects(agent)
513
+ agents = (agent ? [agent] : Agent::Sessions.agents) - @skipped_agents
514
+ count = agents.sum do |a|
515
+ Agent::Sessions.unresolved_project_count(a, env: @env)
516
+ rescue MissingDependency, UnreadableStore
517
+ 0
518
+ end
519
+ @stderr.puts "#{count} sessions with unresolved project" if count.positive?
520
+ end
521
+
522
+ def human_bytes(bytes)
523
+ return "0 B" if bytes.zero?
524
+
525
+ exp = (Math.log(bytes) / Math.log(1024)).floor.clamp(0, 4)
526
+ return "#{bytes} B" if exp.zero?
527
+
528
+ format("%.1f %s", bytes.to_f / (1024**exp), %w[B KB MB GB TB][exp])
529
+ end
530
+ end
531
+ end
532
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # A point where the agent replaced earlier turns with a summary. Not a
6
+ # message: its own payload restates turns already yielded, so anyone counting
7
+ # would count them twice. replaced_count is how many turns it stood in for.
8
+ Compaction = Data.define(:at, :replaced_count, :raw)
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ EnvOverride = Data.define(:name, :value) do
6
+ def active? = !value.to_s.empty?
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ class Error < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # Shared path expansion for Adapters::Base and Audit. Expands "~" against the
6
+ # injected env so callers can resolve paths for a machine that is not their
7
+ # own; joins relative paths (including "~user"-looking strings that are not a
8
+ # real shell lookup here) under that same home; and treats an explicitly empty
9
+ # HOME the same as an absent one.
10
+ module HomeExpansion
11
+ private
12
+
13
+ def expand(path)
14
+ case path
15
+ when %r{\A~(/|\z)} then File.expand_path(path.sub(%r{\A~}) { home })
16
+ when /\A~/ then File.expand_path(path, home)
17
+ else File.absolute_path?(path) ? File.expand_path(path) : File.expand_path(path, home)
18
+ end
19
+ end
20
+
21
+ def home
22
+ value = @env["HOME"]
23
+ value && !value.strip.empty? ? value : Dir.home
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # One resolved layer of an agent's store.
6
+ #
7
+ # A location is one of three shapes, and `files` answers each differently:
8
+ #
9
+ # glob a directory plus a pattern -> the pattern's matches
10
+ # single_file one file (a `path:` store) -> itself, if it is there
11
+ # directory a directory with no known shape -> nothing, and enumerable? is false
12
+ #
13
+ # The third shape is a store whose internal layout this gem has not learned yet
14
+ # (opencode's pre-1.2.0 storage/ tree, Cursor's acp-sessions/). It returns [] so a
15
+ # caller sweeping every layer does not blow up, and answers enumerable? false so that
16
+ # caller can tell "nothing here to enumerate" apart from "enumerated, found none".
17
+ #
18
+ # single_file comes from the adapter's store DSL: `path:` means one file, `dir:` means
19
+ # a directory. Resolution used to discard that distinction, which made a Layer 2
20
+ # enumerator written as layers.flat_map(&:files) silently skip history.jsonl,
21
+ # session_index.jsonl and secrets.json — a missing-session bug, not a visible error.
22
+ Location = Data.define(:kind, :path, :format, :glob, :single_file) do
23
+ def initialize(kind:, path:, format:, glob: nil, single_file: false)
24
+ super
25
+ end
26
+
27
+ def exists? = File.exist?(path)
28
+
29
+ def enumerable? = single_file || !glob.nil?
30
+
31
+ def files
32
+ return exists? ? [path] : [] if single_file
33
+ return [] unless glob
34
+
35
+ Dir.glob(File.join(escaped_path, glob))
36
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
37
+ []
38
+ end
39
+
40
+ private
41
+
42
+ # Only the path is escaped, never the glob. A resolved path may legitimately
43
+ # contain glob metacharacters (a project directory named "app [old]"), and
44
+ # unescaped they would be read as syntax and silently match nothing.
45
+ def escaped_path
46
+ path.gsub(/[\\{}\[\]*?]/) { |char| "\\#{char}" }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # One turn. `role` is normalized to :user, :assistant, :system or :tool, with
6
+ # :unknown for a role the adapter did not recognize — the four the spec names
7
+ # were written before any real corpus was read, and Codex promptly said
8
+ # "developer".
9
+ #
10
+ # raw is never dropped (Layer 3 rule 1): when this normalization is wrong or
11
+ # incomplete, a caller escapes the abstraction instead of forking the gem.
12
+ #
13
+ # usage and model are nil wherever the format does not put them on the
14
+ # message itself — Codex records tokens in separate event records and the
15
+ # model in its session header, so its messages carry neither; the reader's
16
+ # session-level `usage` is where those formats answer. A nil here means
17
+ # "not recorded on this message", never "zero tokens".
18
+ Message = Data.define(:role, :at, :parts, :raw, :usage, :model) do
19
+ self::ROLES = %i[user assistant system tool unknown].freeze
20
+
21
+ def initialize(role:, at:, parts:, raw:, usage: nil, model: nil)
22
+ roles = self.class::ROLES
23
+ raise ArgumentError, "role #{role.inspect} must be one of #{roles.join(", ")}" unless roles.include?(role)
24
+
25
+ super
26
+ end
27
+
28
+ # Concatenated :text parts, as the design doc specifies — no separator
29
+ # inserted, because a separator is a formatting decision this layer has no
30
+ # business making. A caller that needs the boundaries has `parts`.
31
+ def text
32
+ parts.select { |part| part.type == :text }.map(&:text).join
33
+ end
34
+ end
35
+ end
36
+ end