agent_sessions 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/exe/agent-sessions +8 -0
- data/lib/agent/sessions/adapters/amp.rb +162 -0
- data/lib/agent/sessions/adapters/base.rb +259 -0
- data/lib/agent/sessions/adapters/claude.rb +123 -0
- data/lib/agent/sessions/adapters/codex.rb +121 -0
- data/lib/agent/sessions/adapters/copilot.rb +128 -0
- data/lib/agent/sessions/adapters/cursor.rb +176 -0
- data/lib/agent/sessions/adapters/cursor_ide.rb +136 -0
- data/lib/agent/sessions/adapters/enumeration.rb +252 -0
- data/lib/agent/sessions/adapters/gemini.rb +133 -0
- data/lib/agent/sessions/adapters/grok.rb +122 -0
- data/lib/agent/sessions/adapters/opencode.rb +322 -0
- data/lib/agent/sessions/adapters/pi.rb +185 -0
- data/lib/agent/sessions/adapters/qwen.rb +52 -0
- data/lib/agent/sessions/audit.rb +71 -0
- data/lib/agent/sessions/check.rb +9 -0
- data/lib/agent/sessions/cli.rb +532 -0
- data/lib/agent/sessions/compaction.rb +10 -0
- data/lib/agent/sessions/env_override.rb +9 -0
- data/lib/agent/sessions/error.rb +7 -0
- data/lib/agent/sessions/home_expansion.rb +27 -0
- data/lib/agent/sessions/location.rb +50 -0
- data/lib/agent/sessions/message.rb +36 -0
- data/lib/agent/sessions/missing_dependency.rb +7 -0
- data/lib/agent/sessions/node.rb +15 -0
- data/lib/agent/sessions/part.rb +24 -0
- data/lib/agent/sessions/readers/amp.rb +130 -0
- data/lib/agent/sessions/readers/base.rb +282 -0
- data/lib/agent/sessions/readers/claude.rb +281 -0
- data/lib/agent/sessions/readers/codex.rb +234 -0
- data/lib/agent/sessions/readers/copilot.rb +80 -0
- data/lib/agent/sessions/readers/gemini.rb +171 -0
- data/lib/agent/sessions/readers/grok.rb +155 -0
- data/lib/agent/sessions/readers/opencode.rb +224 -0
- data/lib/agent/sessions/readers/pi.rb +129 -0
- data/lib/agent/sessions/readers/qwen.rb +122 -0
- data/lib/agent/sessions/session.rb +75 -0
- data/lib/agent/sessions/sqlite.rb +55 -0
- data/lib/agent/sessions/store.rb +15 -0
- data/lib/agent/sessions/unknown_agent.rb +7 -0
- data/lib/agent/sessions/unreadable_store.rb +7 -0
- data/lib/agent/sessions/unsupported_format.rb +7 -0
- data/lib/agent/sessions/usage.rb +45 -0
- data/lib/agent/sessions/version.rb +7 -0
- data/lib/agent/sessions.rb +199 -0
- data/lib/agent_sessions.rb +1 -0
- metadata +124 -0
|
@@ -0,0 +1,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,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
|