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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ module Readers
6
+ # Qwen Code chat files. PROVISIONAL, like the adapter: written against
7
+ # tokentelemetry's parser of this format, not against real Qwen output.
8
+ #
9
+ # The record shape is Anthropic's — type user/assistant, message.content
10
+ # as an array of typed parts, message.usage with the same five spellings
11
+ # Claude uses. This is deliberately NOT a subclass of Readers::Claude
12
+ # despite that overlap: Claude's reader also carries Claude Code's sidecar
13
+ # machinery (spilled tool output, subagent transcripts, uuid/parentUuid
14
+ # branching), none of which is known to exist here, and inheriting would
15
+ # mean disabling each one and then re-checking every future Claude change
16
+ # against an agent nobody can test. Two readers with two evidence bases
17
+ # will drift honestly; one reader pretending to serve both will drift
18
+ # silently.
19
+ class Qwen < Base
20
+ CONTENT_PARTS = { "text" => :text, "thinking" => :thinking, "tool_use" => :tool_use,
21
+ "tool_result" => :tool_result, "image" => :image }.freeze
22
+
23
+ # Session totals, deduplicated by message.id the way Claude's are: the
24
+ # same API response can stream into one record per content block, and
25
+ # both agents speak the same wire format. Unverified for Qwen — if its
26
+ # writer does not repeat ids, this dedup is simply a no-op.
27
+ def usage
28
+ seen = {}
29
+ total = nil
30
+ each_record do |record, _line_number|
31
+ usage = usage_from(record)
32
+ next unless usage
33
+
34
+ id = record.dig("message", "id")
35
+ next if id && seen[id]
36
+
37
+ seen[id] = true if id
38
+ total = total ? total + usage : usage
39
+ end
40
+ total
41
+ end
42
+
43
+ private
44
+
45
+ def message_for(record, line_number)
46
+ type = record["type"]
47
+ return nil if type == "summary"
48
+
49
+ unless %w[user assistant].include?(type)
50
+ warn_about("line #{line_number}: unrecognized record type #{type.inspect}")
51
+ return build(record, :unknown, [Part.new(type: :unknown)])
52
+ end
53
+
54
+ build(record, type.to_sym, content_parts(record, line_number))
55
+ end
56
+
57
+ # content is an array of parts, or a bare String saying the same thing
58
+ # shorter — both spellings appear in this wire format.
59
+ def content_parts(record, line_number)
60
+ content = record.dig("message", "content")
61
+ return [Part.new(type: :text, text: content)] if content.is_a?(String)
62
+
63
+ Array(content).map { |item| part_for(item, line_number) }
64
+ end
65
+
66
+ def part_for(item, line_number)
67
+ return Part.new(type: :unknown) unless item.is_a?(Hash)
68
+
69
+ case CONTENT_PARTS[item["type"]]
70
+ when :text then Part.new(type: :text, text: item["text"].to_s)
71
+ when :thinking then Part.new(type: :thinking, text: item["thinking"].to_s)
72
+ when :image then Part.new(type: :image)
73
+ when :tool_use
74
+ Part.new(type: :tool_use, name: item["name"], call_id: item["id"],
75
+ text: stringify(item["input"]))
76
+ when :tool_result
77
+ Part.new(type: :tool_result, call_id: item["tool_use_id"],
78
+ text: flatten_result(item["content"]))
79
+ else
80
+ warn_about("line #{line_number}: unrecognized content part #{item["type"].inspect}")
81
+ Part.new(type: :unknown, text: item["text"])
82
+ end
83
+ end
84
+
85
+ def flatten_result(content)
86
+ return content if content.is_a?(String)
87
+
88
+ Array(content).filter_map { |item| item["text"] if item.is_a?(Hash) && item["type"] == "text" }.join
89
+ end
90
+
91
+ def build(record, role, parts)
92
+ model = record.dig("message", "model")
93
+ Message.new(role: role, at: time_from(record["timestamp"]), parts: parts, raw: record,
94
+ usage: usage_from(record), model: model.is_a?(String) ? model : nil)
95
+ end
96
+
97
+ # The Anthropic spelling, where input_tokens is already disjoint from
98
+ # the cache counts — so unlike Gemini's and Codex's, nothing is
99
+ # subtracted here. ephemeral_1h_input_tokens is a cache-creation count
100
+ # at a different TTL; it is added to cache_creation rather than given a
101
+ # bucket of its own, because this gem's Usage does not model TTL and
102
+ # dropping it would under-report what was written to cache.
103
+ def usage_from(record)
104
+ usage = record.dig("message", "usage")
105
+ return nil unless usage.is_a?(Hash)
106
+
107
+ creation = count_from(usage["cache_creation_input_tokens"])
108
+ hourly = count_from(usage.dig("cache_creation", "ephemeral_1h_input_tokens"))
109
+ mapped = Usage.new(input: count_from(usage["input_tokens"]),
110
+ output: count_from(usage["output_tokens"]),
111
+ cache_read: count_from(usage["cache_read_input_tokens"]),
112
+ cache_creation: creation || hourly ? creation.to_i + hourly.to_i : nil)
113
+ mapped.to_h.each_value.any? ? mapped : nil
114
+ end
115
+
116
+ def stringify(value)
117
+ value.is_a?(String) || value.nil? ? value.to_s : JSON.generate(value)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # One recorded conversation. Everything here comes from a stat or the store's
6
+ # own metadata — except project_path, which may need to read inside the session
7
+ # file (design doc section 7: the on-disk directory encodings are lossy, so the
8
+ # recorded cwd inside the file is the only reliable source). It is computed on
9
+ # first access and memoized, which is why this is a plain class rather than a
10
+ # frozen Data: an instance is immutable except for that one memo.
11
+ #
12
+ # Equality is identity, not value — unlike every sibling value object here, which
13
+ # gets value equality for free from Data. A value comparison would have to force
14
+ # project_path on both operands, turning a `uniq` over thousands of sessions into
15
+ # the full content sweep the design works to avoid. A caller keying a mixed-agent
16
+ # collection should use `uid`, which exists for exactly that. Note `to_h` includes
17
+ # `uid`, so its output does not round-trip back through `new`.
18
+ class Session
19
+ UNRESOLVED = Object.new.freeze
20
+ private_constant :UNRESOLVED
21
+
22
+ attr_reader :agent, :id, :path, :started_at, :updated_at, :bytes, :format, :fidelity
23
+
24
+ def initialize(agent:, id:, path:, started_at:, updated_at:, bytes:, format:, fidelity:,
25
+ project_path: UNRESOLVED, &project_path_resolver)
26
+ @agent = agent
27
+ @id = id
28
+ @path = path
29
+ @started_at = started_at
30
+ @updated_at = updated_at
31
+ @bytes = bytes
32
+ @format = format
33
+ @fidelity = fidelity
34
+ @project_path = project_path
35
+ @project_path_resolver = project_path_resolver
36
+ end
37
+
38
+ # Collision-free across a mixed-agent collection, where bare ids may repeat.
39
+ def uid = "#{agent}:#{id}"
40
+
41
+ # A resolver that raises is deliberately not memoized: a failed read is not an
42
+ # answer, so the next call retries rather than freezing the failure in place.
43
+ #
44
+ # Not thread-safe by design: concurrent first access can run the resolver more
45
+ # than once, but every run yields the same value and the assignment is atomic
46
+ # on MRI, so there is no torn read to guard against. Do not add a mutex.
47
+ def project_path
48
+ return @project_path unless @project_path.equal?(UNRESOLVED)
49
+
50
+ @project_path = @project_path_resolver&.call
51
+ @project_path_resolver = nil
52
+ @project_path
53
+ end
54
+
55
+ # The honest full dump — includes project_path, so it forces that read.
56
+ # Callers listing thousands of sessions should build their own slimmer rows.
57
+ def to_h
58
+ {
59
+ agent: agent, id: id, uid: uid, path: path, project_path: project_path,
60
+ started_at: started_at, updated_at: updated_at,
61
+ bytes: bytes, format: format, fidelity: fidelity
62
+ }
63
+ end
64
+
65
+ # Never calls project_path: inspecting a session in a debugger must not
66
+ # trigger the read that enumeration deliberately deferred.
67
+ def inspect
68
+ resolved = @project_path.equal?(UNRESOLVED) ? "(unresolved)" : @project_path.inspect
69
+ "#<#{self.class.name} agent: #{agent.inspect}, id: #{id.inspect}, path: #{path.inspect}, " \
70
+ "project_path: #{resolved}, started_at: #{started_at.inspect}, updated_at: #{updated_at.inspect}, " \
71
+ "bytes: #{bytes.inspect}, format: #{format.inspect}, fidelity: #{fidelity.inspect}>"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # The one way this gem opens a SQLite store: read-only, URI-escaped, with a
6
+ # bounded retry against a live writer's lock. Extracted from the opencode
7
+ # adapter when the opencode reader became its second caller — two copies of
8
+ # escape_uri_path would drift, and the bug it guards is subtle enough that a
9
+ # drifted copy would look correct in review.
10
+ #
11
+ # What differs between callers stays with them: the adapter raises
12
+ # UnreadableStore on a query failure because a vanished database means no
13
+ # sessions at all, while a reader warns and yields nothing because one
14
+ # unreadable session must not take down a sweep (Layer 3 rule 2). This
15
+ # module only opens; it never decides what a failure means.
16
+ module Sqlite
17
+ module_function
18
+
19
+ # No immutable=1: it tells SQLite to trust that the file will never change
20
+ # and skip locking AND the WAL entirely — against a live, WAL-mode
21
+ # opencode.db that means silently missing every committed-but-not-yet-
22
+ # checkpointed session. Opening a WAL db even read-only touches its -shm
23
+ # and -wal sidecars (SQLite's own reader bookkeeping, confirmed directly);
24
+ # the recorded sessions themselves are never written.
25
+ #
26
+ # busy_timeout gives SQLite up to 5s to retry internally against a lock
27
+ # held by the agent's own live writer. A WAL writer's lock is normally held
28
+ # only for the instant of a commit, so a lock that has not cleared within
29
+ # 5s is a stuck process, not ordinary contention.
30
+ def open_readonly(path)
31
+ db = SQLite3::Database.new(
32
+ "file:#{escape_uri_path(path)}?mode=ro",
33
+ flags: SQLite3::Constants::Open::READONLY | SQLite3::Constants::Open::URI
34
+ )
35
+ db.busy_timeout = 5_000
36
+ db
37
+ end
38
+
39
+ # IMPORTANT, caught in review: SQLite's URI parser gives `%`, `#` and `?`
40
+ # syntactic meaning, and the path was being interpolated raw. `#` starts a
41
+ # fragment (silently truncating the path there); `?` starts the query
42
+ # string, colliding with the `?mode=ro` open_readonly appends. The worst
43
+ # case, confirmed directly: a path segment that merely CONTAINS a
44
+ # valid-looking percent-escape — a directory literally named "a%23b" —
45
+ # gets that escape DECODED by the URI parser into a different path
46
+ # ("a#b"), so a second, unrelated database sitting at THAT path is read
47
+ # instead, silently, with no exception at all. One pass, not two
48
+ # sequential gsubs: escaping # to %23 and THEN escaping the % that
49
+ # produced would double-encode it to %2523.
50
+ def escape_uri_path(path)
51
+ path.gsub(/[%#?]/) { format("%%%02X", _1.ord) }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ Store = Data.define(
6
+ :agent, :label, :documented, :verified_on,
7
+ :effective, :layers, :env_overrides,
8
+ :retention, :retention_source, :warnings
9
+ ) do
10
+ def documented? = documented == true
11
+ def installed? = layers.any?(&:exists?)
12
+ def format = effective.format
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ class UnknownAgent < Error; end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ class UnreadableStore < Error; end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ class UnsupportedFormat < Error; end
6
+ end
7
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # Token counts an agent reported, for one message or one whole session,
6
+ # normalized to five DISJOINT buckets: `input` never includes what was read
7
+ # from or written to cache, and `output` never includes `reasoning`. Agents
8
+ # disagree here — Codex's input_tokens includes its cached_input_tokens,
9
+ # Claude's does not (both verified against real stores on this machine,
10
+ # 2026-08-24) — and a caller summing across agents needs one rule, not one
11
+ # per agent. Readers do the subtraction; this object only holds the result.
12
+ #
13
+ # nil means "this format does not record that dimension", and it is load-
14
+ # bearing: absence must never read as zero, for the same reason
15
+ # Agent::Sessions.read raises on a format with no reader. `cost` is reported
16
+ # by the agent or absent — never derived from a pricing table, which would
17
+ # go stale in a gem and is a consumer's decision anyway.
18
+ Usage = Data.define(:input, :output, :cache_read, :cache_creation, :reasoning, :cost) do
19
+ def initialize(input: nil, output: nil, cache_read: nil, cache_creation: nil, reasoning: nil, cost: nil)
20
+ super
21
+ end
22
+
23
+ # Sums dimension-wise, keeping the nil/zero distinction: nil + nil stays
24
+ # nil ("neither side records this"), nil + n is n — one recorded value is
25
+ # a real value, not a value plus an unknown, because per-message absence
26
+ # under a format that does record the dimension means "none reported for
27
+ # this message", the one place absence and zero do coincide.
28
+ def +(other)
29
+ self.class.new(input: sum(input, other.input), output: sum(output, other.output),
30
+ cache_read: sum(cache_read, other.cache_read),
31
+ cache_creation: sum(cache_creation, other.cache_creation),
32
+ reasoning: sum(reasoning, other.reasoning), cost: sum(cost, other.cost))
33
+ end
34
+
35
+ private
36
+
37
+ def sum(mine, theirs)
38
+ return theirs if mine.nil?
39
+ return mine if theirs.nil?
40
+
41
+ mine + theirs
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ VERSION = "0.3.0"
6
+ end
7
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "date"
5
+ require "uri"
6
+ require "time"
7
+
8
+ require "agent_homedir"
9
+ require "zeitwerk"
10
+
11
+ module Agent
12
+ module Sessions
13
+ LOADER = Zeitwerk::Loader.for_gem_extension(Agent)
14
+ LOADER.inflector.inflect("cli" => "CLI")
15
+ LOADER.setup
16
+ private_constant :LOADER
17
+
18
+ STALE_AFTER_DAYS = 90
19
+
20
+ class << self
21
+ # Re-registering a name deliberately replaces it, so a consumer can ship a
22
+ # corrected adapter for an agent whose layout moved before the gem catches up.
23
+ def register(adapter_class)
24
+ name = adapter_class.agent_name
25
+ raise Error, "#{adapter_class.inspect} declares no agent name" if name.nil?
26
+
27
+ registry[name] = adapter_class
28
+ end
29
+
30
+ def registry
31
+ @registry ||= {}
32
+ end
33
+
34
+ def agents = registry.keys
35
+
36
+ def locate(agent, env: ENV)
37
+ adapter_for(agent).new(env: env).locate
38
+ end
39
+
40
+ def all(env: ENV)
41
+ registry.keys.map { |agent| locate(agent, env: env) }
42
+ end
43
+
44
+ def installed(env: ENV)
45
+ all(env: env).select(&:installed?)
46
+ end
47
+
48
+ # Lazy: consuming N sessions stats N files, never more. `since`, when given,
49
+ # must be a Time (or anything Time#>= accepts) — comparing updated_at (always
50
+ # a Time; every adapter populates it, from mtime or store metadata) against a
51
+ # Date, Integer, or String raises ArgumentError("comparison of Time with ...
52
+ # failed"), which already names the mistake, so no extra guard is added here.
53
+ # That raise happens on enumeration, not on this call, because the filter
54
+ # itself is lazy — `sessions(:x, since: bad).first(1)` can raise from inside
55
+ # `first`, not from this line.
56
+ # Raises MissingDependency or UnreadableStore for opencode under the same
57
+ # conditions described on for_project below.
58
+ def sessions(agent, env: ENV, since: nil)
59
+ list = adapter_for(agent).new(env: env).sessions
60
+ since ? list.select { |session| session.updated_at >= since } : list
61
+ end
62
+
63
+ # One project across every agent (or the agents: subset), lazily: adapters
64
+ # earlier in the sweep satisfy `first(n)` without the later ones ever being
65
+ # asked. Within one adapter, though, laziness cannot skip non-matching
66
+ # sessions — sessions_for_project must still stat and check each candidate
67
+ # to know it does not match, so an adapter with zero matches costs a full
68
+ # scan of its store before the sweep moves on. True of the six Base-driven
69
+ # adapters; opencode pushes the filter into SQL (WHERE directory = ?) and
70
+ # stats nothing.
71
+ #
72
+ # Deliberately does NOT rescue MissingDependency or UnreadableStore: opencode
73
+ # without the sqlite3 gem, or with a corrupt/locked database, raises. Since
74
+ # `flat_map` is lazy, that raise surfaces only once enumeration reaches the
75
+ # failing adapter — possibly after other agents' sessions have already been
76
+ # yielded to the caller mid-iteration, and possibly not at all if `first(n)`
77
+ # is satisfied first. Silently omitting an agent's sessions is this gem's
78
+ # worst failure mode (design doc decision 11), so this method never trades
79
+ # a raised, attributable error for a quietly incomplete list. A caller that
80
+ # wants the sweep to survive one bad agent should rescue per call, e.g. by
81
+ # driving `agents:` itself and catching around each adapter; a caller who
82
+ # just wants to route around a known-bad agent can pass `agents:` naming
83
+ # every registered agent except it. The CLI (Task 10) does the former,
84
+ # turning the same exceptions into per-agent "skipped" lines instead of one
85
+ # failed sweep. Note a rescue cannot resume this enumerator: re-calling each
86
+ # after a raise re-raises from the same adapter. The only recovery is a
87
+ # fresh call with a narrower `agents:`.
88
+ # Adapters are resolved eagerly, so an unknown name in `agents:` raises here
89
+ # rather than mid-sweep. The deferral above is about DATA conditions, where
90
+ # the raise carries information about a store; a typo'd agent symbol is a
91
+ # programmer error knowable before any I/O, and `agents: [:claude, :nope]`
92
+ # otherwise hands back Claude's sessions and then crashes. Base#initialize
93
+ # only stores @env, so constructing all seven up front costs nothing, and
94
+ # the sweep stays lazy — first(n) still stops at the first matching adapter.
95
+ def for_project(dir, env: ENV, agents: nil)
96
+ dir = File.expand_path(dir)
97
+ adapters = (agents || registry.keys).map { |name| adapter_for(name).new(env: env) }
98
+ adapters.lazy.flat_map { |adapter| adapter.sessions_for_project(dir) }
99
+ end
100
+
101
+ # Eager, unlike sessions/for_project: project_paths already reads every
102
+ # session to answer (design doc section 7 — the on-disk encodings are lossy,
103
+ # so the recorded cwd is the only reliable source), sorts, and dedupes, so a
104
+ # lazy return type here would promise a laziness the work underneath cannot
105
+ # honor. Returns a plain, already-sorted Array.
106
+ # Raises MissingDependency or UnreadableStore for opencode, as sessions does.
107
+ def projects(agent, env: ENV)
108
+ adapter_for(agent).new(env: env).project_paths
109
+ end
110
+
111
+ # Companion to `projects`/`project_paths`, which both exclude a session
112
+ # whose project could not be resolved rather than counting it (design doc
113
+ # section 7) — so "this agent genuinely records no projects" and "this
114
+ # agent's project resolution is broken" read identically from the
115
+ # outside. Three of seven adapters can legitimately return a nil
116
+ # project_path (Amp threads with no `trees`, cursor_ide by design, pi
117
+ # whenever its unverified header assumption is wrong); this counts it for
118
+ # any of them, uniformly, using only the public `sessions` enumerator —
119
+ # no adapter needs to know this exists. Eager and a second full sweep of
120
+ # the store, same cost class as `projects` itself, so it is opt-in
121
+ # (called by the CLI only under `list --project`, never under plain
122
+ # `list`) rather than folded into `projects`' own return value, which is
123
+ # a documented, tested plain Array and would otherwise need a shape
124
+ # change to carry both numbers.
125
+ # Raises MissingDependency or UnreadableStore for opencode, as sessions does.
126
+ def unresolved_project_count(agent, env: ENV)
127
+ adapter_for(agent).new(env: env).sessions.count { |session| session.project_path.nil? }
128
+ end
129
+
130
+ # Layer 3. Takes a Session (from `sessions`, `for_project`), not an agent
131
+ # name, because reading is per-session — the adapter comes from the session
132
+ # itself. Raises UnsupportedFormat for an agent with no reader yet, rather
133
+ # than returning a reader that yields nothing: "this gem cannot read that
134
+ # format" and "that session has no messages" must never look alike.
135
+ #
136
+ # include_events: true adds the agent's UI-level records to the stream where
137
+ # an adapter has them. They are excluded by default because they are
138
+ # bookkeeping, not conversation, and they outnumber real messages.
139
+ def read(session, **options)
140
+ klass = adapter_for(session.agent).reader_class
141
+ unless klass
142
+ raise UnsupportedFormat,
143
+ "no reader for #{session.agent} yet; Session#fidelity says #{session.fidelity}"
144
+ end
145
+
146
+ klass.new(session, **options)
147
+ end
148
+
149
+ def verify(agent = nil, env: ENV)
150
+ targets = agent ? [adapter_for(agent)] : registry.values
151
+ targets.flat_map { |klass| klass.new(env: env).verify }
152
+ end
153
+
154
+ def doctor(agent = nil, env: ENV, today: Date.today)
155
+ targets = agent ? [adapter_for(agent)] : registry.values
156
+ staleness = targets.map do |klass|
157
+ age = (today - klass.verified_on_date).to_i
158
+ if age > STALE_AFTER_DAYS
159
+ Check.new(agent: klass.agent_name, status: :drift, claim: "verified within #{STALE_AFTER_DAYS} days",
160
+ detail: "last verified #{klass.verified_on_date} (#{age} days ago)")
161
+ else
162
+ Check.new(agent: klass.agent_name, status: :pass, claim: "verified within #{STALE_AFTER_DAYS} days",
163
+ detail: "last verified #{klass.verified_on_date}")
164
+ end
165
+ end
166
+ verify(agent, env: env) + staleness
167
+ end
168
+
169
+ def audit(env: ENV)
170
+ Audit.new(all(env: env), env: env).report
171
+ end
172
+
173
+ private
174
+
175
+ def adapter_for(agent)
176
+ registry.fetch(agent) do
177
+ raise UnknownAgent, "unknown agent: #{agent.inspect} (known: #{registry.keys.join(", ")})"
178
+ end
179
+ end
180
+ end
181
+
182
+ register(Adapters::Claude)
183
+ register(Adapters::Codex)
184
+ register(Adapters::Pi)
185
+ register(Adapters::Amp)
186
+ register(Adapters::Opencode)
187
+ register(Adapters::Cursor)
188
+ register(Adapters::CursorIde)
189
+ register(Adapters::Gemini)
190
+ register(Adapters::Qwen)
191
+ register(Adapters::Copilot)
192
+ register(Adapters::Grok)
193
+
194
+ # The oldest verified_on among the built-in adapters. A claim about somebody
195
+ # else's software is only as current as its weakest link, so this is the honest
196
+ # answer to "when was this last known to be true".
197
+ VERIFIED_ON = registry.values.map(&:verified_on_date).min
198
+ end
199
+ end
@@ -0,0 +1 @@
1
+ require_relative "agent/sessions"
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: agent_sessions
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Lucian Ghinda
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: agent_homedir
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: zeitwerk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.8'
40
+ description: Supports 11 adapters for Claude Code, Codex CLI, Cursor CLI/IDE, Amp,
41
+ opencode, pi, Gemini CLI, GitHub Copilot CLI, Qwen Code, and Grok Build; reads messages
42
+ for nine agents, verifies store paths, maps sessions to projects, and audits sync
43
+ exposure.
44
+ email:
45
+ - dev@ghinda.com
46
+ executables:
47
+ - agent-sessions
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - CHANGELOG.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - exe/agent-sessions
55
+ - lib/agent/sessions.rb
56
+ - lib/agent/sessions/adapters/amp.rb
57
+ - lib/agent/sessions/adapters/base.rb
58
+ - lib/agent/sessions/adapters/claude.rb
59
+ - lib/agent/sessions/adapters/codex.rb
60
+ - lib/agent/sessions/adapters/copilot.rb
61
+ - lib/agent/sessions/adapters/cursor.rb
62
+ - lib/agent/sessions/adapters/cursor_ide.rb
63
+ - lib/agent/sessions/adapters/enumeration.rb
64
+ - lib/agent/sessions/adapters/gemini.rb
65
+ - lib/agent/sessions/adapters/grok.rb
66
+ - lib/agent/sessions/adapters/opencode.rb
67
+ - lib/agent/sessions/adapters/pi.rb
68
+ - lib/agent/sessions/adapters/qwen.rb
69
+ - lib/agent/sessions/audit.rb
70
+ - lib/agent/sessions/check.rb
71
+ - lib/agent/sessions/cli.rb
72
+ - lib/agent/sessions/compaction.rb
73
+ - lib/agent/sessions/env_override.rb
74
+ - lib/agent/sessions/error.rb
75
+ - lib/agent/sessions/home_expansion.rb
76
+ - lib/agent/sessions/location.rb
77
+ - lib/agent/sessions/message.rb
78
+ - lib/agent/sessions/missing_dependency.rb
79
+ - lib/agent/sessions/node.rb
80
+ - lib/agent/sessions/part.rb
81
+ - lib/agent/sessions/readers/amp.rb
82
+ - lib/agent/sessions/readers/base.rb
83
+ - lib/agent/sessions/readers/claude.rb
84
+ - lib/agent/sessions/readers/codex.rb
85
+ - lib/agent/sessions/readers/copilot.rb
86
+ - lib/agent/sessions/readers/gemini.rb
87
+ - lib/agent/sessions/readers/grok.rb
88
+ - lib/agent/sessions/readers/opencode.rb
89
+ - lib/agent/sessions/readers/pi.rb
90
+ - lib/agent/sessions/readers/qwen.rb
91
+ - lib/agent/sessions/session.rb
92
+ - lib/agent/sessions/sqlite.rb
93
+ - lib/agent/sessions/store.rb
94
+ - lib/agent/sessions/unknown_agent.rb
95
+ - lib/agent/sessions/unreadable_store.rb
96
+ - lib/agent/sessions/unsupported_format.rb
97
+ - lib/agent/sessions/usage.rb
98
+ - lib/agent/sessions/version.rb
99
+ - lib/agent_sessions.rb
100
+ homepage: https://github.com/lucianghinda/agent_sessions
101
+ licenses:
102
+ - MIT
103
+ metadata:
104
+ homepage_uri: https://github.com/lucianghinda/agent_sessions
105
+ changelog_uri: https://github.com/lucianghinda/agent_sessions/blob/main/CHANGELOG.md
106
+ rubygems_mfa_required: 'true'
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: 3.2.0
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 4.0.11
122
+ specification_version: 4
123
+ summary: Locate, verify, and read AI coding agent session logs
124
+ test_files: []