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,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,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,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: []
|