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,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
class Claude < Base
|
|
7
|
+
agent :claude
|
|
8
|
+
label "Claude Code"
|
|
9
|
+
documented true
|
|
10
|
+
verified_on "2026-08-05"
|
|
11
|
+
fidelity :full
|
|
12
|
+
|
|
13
|
+
homedir :claude_code
|
|
14
|
+
|
|
15
|
+
store :projects, dir: "projects", glob: "*/*.jsonl", format: :jsonl
|
|
16
|
+
store :history, path: "history.jsonl", format: :jsonl, optional: true
|
|
17
|
+
|
|
18
|
+
def self.reader_class = Readers::Claude
|
|
19
|
+
|
|
20
|
+
DEFAULT_CLEANUP_PERIOD_DAYS = 30
|
|
21
|
+
|
|
22
|
+
def retention
|
|
23
|
+
configured_retention || DEFAULT_CLEANUP_PERIOD_DAYS
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def retention_source
|
|
27
|
+
configured_retention ? :setting : :default
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def warnings
|
|
31
|
+
list = super
|
|
32
|
+
if env_active?("CLAUDE_CODE_SKIP_PROMPT_HISTORY")
|
|
33
|
+
list << "CLAUDE_CODE_SKIP_PROMPT_HISTORY is set: history.jsonl is not being written"
|
|
34
|
+
end
|
|
35
|
+
list
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Every non-alphanumeric character becomes "-" (design doc section 7).
|
|
39
|
+
# dir must already be absolute and expanded — sessions_for_project
|
|
40
|
+
# guarantees that; a direct caller passing "app", "~/app", or a
|
|
41
|
+
# trailing slash gets a nonsense encoding (see Base#encode_project).
|
|
42
|
+
# Verified against real project directories on 2026-08-05:
|
|
43
|
+
# /Users/dev/.local -> -Users-dev--local
|
|
44
|
+
def encode_project(dir)
|
|
45
|
+
dir.gsub(/[^a-zA-Z0-9]/, "-")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# cwd is NOT on line 1. Real sessions open with a kebab-case preamble
|
|
49
|
+
# (ai-title, agent-name, mode, permission-mode) followed by a
|
|
50
|
+
# variable-length run of file-history-snapshot records — that run is
|
|
51
|
+
# what pushes the first cwd-bearing record out further on some files,
|
|
52
|
+
# and nothing bounds its length. Observed on this machine on 2026-08-05:
|
|
53
|
+
# line 3 (19 files), line 4 (48 files), line 9 (1 file, a longer
|
|
54
|
+
# snapshot run). limit: 25 is ~2.8x that observed maximum — headroom
|
|
55
|
+
# for the variable-length run, not a tight fit to the common case — and
|
|
56
|
+
# keeps this a few-KB read even on multi-GB files.
|
|
57
|
+
#
|
|
58
|
+
# The block guards against a record that carries "cwd" but not usably:
|
|
59
|
+
# null shadows a later valid record, and a wrong type (Integer, Hash)
|
|
60
|
+
# would otherwise reach project_paths' .uniq.sort and raise there.
|
|
61
|
+
# scan_jsonl_for_key already guarantees the key is present once the
|
|
62
|
+
# block accepts, so a plain fetch (no default) is safe.
|
|
63
|
+
def project_path_for(path)
|
|
64
|
+
scan_jsonl_for_key(path, "cwd", limit: 25) { |record| record["cwd"].is_a?(String) }&.fetch("cwd")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Claude Code writes a directory beside each transcript, named after the
|
|
68
|
+
# session id with the extension dropped: subagents/ holds the transcripts
|
|
69
|
+
# of agents this session spawned, tool-results/ holds tool output too
|
|
70
|
+
# large to inline. Those bytes are this session's, and until they were
|
|
71
|
+
# counted `du` reported 122.1 MB for a store `audit` reported 173.0 MB
|
|
72
|
+
# for — 71% — because audit sums the store directory whole while du sums
|
|
73
|
+
# sessions. Two commands, one directory, a 29% disagreement.
|
|
74
|
+
#
|
|
75
|
+
# Measured over 128 real sessions on 2026-08-10: 0.002 ms per session
|
|
76
|
+
# when there is no sidecar (one stat, the common case on a fresh install)
|
|
77
|
+
# and 0.080 ms when there is. That is under half what project_path's
|
|
78
|
+
# content read costs, and unlike project_path this cannot be deferred —
|
|
79
|
+
# bytes is eager, and a lazily-corrected byte total would leave `list`
|
|
80
|
+
# printing one number while `du` summed another.
|
|
81
|
+
def bytes_for(path, stat)
|
|
82
|
+
stat.size + sidecar_bytes(path)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Not File.basename: the sidecar sits beside the transcript, so only the
|
|
88
|
+
# extension comes off. A path with no extension leaves the name unchanged
|
|
89
|
+
# and File.directory? then answers false for the transcript itself.
|
|
90
|
+
#
|
|
91
|
+
# SystemCallError, not a narrower list, and rescued rather than raised
|
|
92
|
+
# for the reason Base#bytes_for's comment gives: this runs eagerly for
|
|
93
|
+
# every session, so an unreadable sidecar must cost its own byte total
|
|
94
|
+
# and nothing else. Missing bytes beat a missing session.
|
|
95
|
+
def sidecar_bytes(path)
|
|
96
|
+
sidecar = path.delete_suffix(File.extname(path))
|
|
97
|
+
# readable? as well as directory?: Dir.glob answers [] for a directory
|
|
98
|
+
# it cannot open, but warns while doing it under -w, which is how the
|
|
99
|
+
# test suite runs. It does not cover an unreadable directory NESTED in
|
|
100
|
+
# a readable sidecar — the rescue below is what covers that.
|
|
101
|
+
return 0 unless File.directory?(sidecar) && File.readable?(sidecar)
|
|
102
|
+
|
|
103
|
+
# FNM_DOTMATCH for Audit#bytes_under's reason: a total that quietly
|
|
104
|
+
# omits dotfiles is worse than no total at all.
|
|
105
|
+
Dir.glob(File.join(escape_glob(sidecar), "**", "*"), File::FNM_DOTMATCH).sum do |entry|
|
|
106
|
+
File.file?(entry) ? File.size(entry) : 0
|
|
107
|
+
end
|
|
108
|
+
rescue SystemCallError
|
|
109
|
+
0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def settings
|
|
113
|
+
@settings ||= read_json(File.join(base_dir, "settings.json"))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def configured_retention
|
|
117
|
+
value = settings["cleanupPeriodDays"]
|
|
118
|
+
value if value.is_a?(Integer) && !value.negative?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
class Codex < Base
|
|
7
|
+
agent :codex
|
|
8
|
+
label "Codex CLI"
|
|
9
|
+
documented false
|
|
10
|
+
verified_on "2026-07-21"
|
|
11
|
+
fidelity :full
|
|
12
|
+
|
|
13
|
+
homedir :codex
|
|
14
|
+
|
|
15
|
+
store :sessions, dir: "sessions", glob: "*/*/*/rollout-*.jsonl", format: :jsonl
|
|
16
|
+
# Flat, unlike sessions/YYYY/MM/DD/ — the one real archived file found
|
|
17
|
+
# (2026-08-10) sat directly in the directory. Optional because most
|
|
18
|
+
# machines have never archived anything, so absence is drift, not a
|
|
19
|
+
# failed claim. Declared second so primary_layer stays `sessions`.
|
|
20
|
+
store :archived, dir: "archived_sessions", glob: "rollout-*.jsonl", format: :jsonl, optional: true
|
|
21
|
+
store :history, path: "history.jsonl", format: :jsonl, optional: true
|
|
22
|
+
store :index, path: "session_index.jsonl", format: :jsonl, optional: true
|
|
23
|
+
|
|
24
|
+
warning "the [history] config section governs history.jsonl only; " \
|
|
25
|
+
"persistence = \"none\" does not stop rollout files"
|
|
26
|
+
|
|
27
|
+
def self.reader_class = Readers::Codex
|
|
28
|
+
|
|
29
|
+
# rollout-<YYYY-MM-DDTHH-MM-SS>-<uuid>.jsonl (verified 2026-08-05 against
|
|
30
|
+
# 360 real session files on this machine — every one matched). The
|
|
31
|
+
# timestamp uses the local clock and dashes where ISO 8601 has colons.
|
|
32
|
+
# started_at_for's comment says what "local" costs elsewhere. The uuid
|
|
33
|
+
# group is pinned to its actual shape (8-4-4-4-12 hex), not (.+): greedy
|
|
34
|
+
# against \.jsonl\z, (.+) would swallow a sync tool's or backup's
|
|
35
|
+
# " (conflicted copy)" suffix into what looks like a canonical id rather
|
|
36
|
+
# than falling back to the basename, where such a copy is at least
|
|
37
|
+
# visibly non-canonical.
|
|
38
|
+
FILENAME = /\Arollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\h{8}-\h{4}-\h{4}-\h{4}-\h{12})\.jsonl\z/
|
|
39
|
+
|
|
40
|
+
# Codex writes rollout files to two stores, and Base enumerates only the
|
|
41
|
+
# primary one. An archived session is still a session — a real one was
|
|
42
|
+
# found outside the sessions/ glob on 2026-08-10 — and a session the gem
|
|
43
|
+
# does not report is the silent under-reporting this design treats as its
|
|
44
|
+
# worst failure mode. Every filename hook below applies unchanged: the
|
|
45
|
+
# archived files carry the same rollout-<timestamp>-<uuid>.jsonl name.
|
|
46
|
+
#
|
|
47
|
+
# `super` first, so the guard it raises when the primary store has no
|
|
48
|
+
# known layout still fires, and so live sessions come out before archived
|
|
49
|
+
# ones. Chained rather than concatenated to keep the result lazy: a
|
|
50
|
+
# caller taking first(n) must not stat an archived file it never asked
|
|
51
|
+
# about.
|
|
52
|
+
def sessions
|
|
53
|
+
super.chain(enumerate(layer(:archived).files)).lazy
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def session_id_from(path)
|
|
57
|
+
captures = FILENAME.match(File.basename(path))&.captures or return super
|
|
58
|
+
|
|
59
|
+
captures.last
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The digit groups accept 00-99 each, which Time.new does not: month 13,
|
|
63
|
+
# minute 60, and similar out-of-range values raise ArgumentError rather
|
|
64
|
+
# than being normalized. That is file DATA, not an adapter bug, so it
|
|
65
|
+
# must not cross the line build_session draws between the two (a raising
|
|
66
|
+
# hook is meant to surface as a programming error) — one such filename
|
|
67
|
+
# among many good ones would otherwise take sessions, project_paths, and
|
|
68
|
+
# sessions_for_project down to zero for every agent, not just Codex.
|
|
69
|
+
#
|
|
70
|
+
# Local, not UTC: session_meta's own "timestamp" field is UTC and agrees
|
|
71
|
+
# with this to within 1s across all 360 real files here, but a machine
|
|
72
|
+
# whose TZ changed, or a store copied from another machine, would make
|
|
73
|
+
# this off by the offset delta while Claude's birthtime-based
|
|
74
|
+
# started_at stays an absolute instant. Harmless today because Task 10
|
|
75
|
+
# sorts sessions by updated_at, not started_at.
|
|
76
|
+
# The rescue wraps Time.new alone rather than the whole method. A
|
|
77
|
+
# method-scoped rescue would also swallow an ArgumentError from a future
|
|
78
|
+
# signature change — the commonest Ruby programming error — and silently
|
|
79
|
+
# return birthtime for every Codex session: a plausible-looking wrong
|
|
80
|
+
# started_at with no signal, which is worse than a crash.
|
|
81
|
+
def started_at_for(path, stat)
|
|
82
|
+
parts = FILENAME.match(File.basename(path))&.captures or return super
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
Time.new(*parts.first(6).map(&:to_i))
|
|
86
|
+
rescue ArgumentError # the digits matched but do not form a real date
|
|
87
|
+
super
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Line 1 is session_meta; the cwd lives in its payload (design doc
|
|
92
|
+
# section 6 and 8.2, verified 2026-08-05 — 360/360 real files carry a
|
|
93
|
+
# usable session_meta/payload/cwd on line 1). limit: 3 is slack against
|
|
94
|
+
# that guarantee, not a fit to any observed multi-line case: it tolerates
|
|
95
|
+
# a truncated or blank first line without paying for an unbounded scan.
|
|
96
|
+
# "3" counts iterations of File.foreach(path, "\n", MAX_LINE_BYTES), not
|
|
97
|
+
# lines: a >1MB record is chunked and each chunk is one iteration, so
|
|
98
|
+
# this is really 3MB of read headroom, not "3 records." A future adapter
|
|
99
|
+
# copying this pattern with limit: 1 would lose that tolerance entirely.
|
|
100
|
+
#
|
|
101
|
+
# The predicate requires more than scan_jsonl_for_key's key-presence
|
|
102
|
+
# check can: real sessions on this machine also carry a "payload" key on
|
|
103
|
+
# later, non-session_meta records (turn_context observed 2026-08-05)
|
|
104
|
+
# whose payload itself carries "cwd" — a presence-only scan would stop
|
|
105
|
+
# at whichever comes first, right only by coincidence. Requiring
|
|
106
|
+
# type == "session_meta" pins the read to the one documented source of
|
|
107
|
+
# truth (design doc 8.2), and requiring a Hash payload with a String cwd
|
|
108
|
+
# stops a malformed record (payload not a Hash, or cwd not a String)
|
|
109
|
+
# from permanently shadowing a later, usable session_meta or reaching
|
|
110
|
+
# project_paths' .uniq.sort with the wrong type.
|
|
111
|
+
def project_path_for(path)
|
|
112
|
+
scan_jsonl_for_key(path, "payload", limit: 3) do |record|
|
|
113
|
+
record["type"] == "session_meta" &&
|
|
114
|
+
record["payload"].is_a?(Hash) &&
|
|
115
|
+
record["payload"]["cwd"].is_a?(String)
|
|
116
|
+
end&.dig("payload", "cwd")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
# GitHub Copilot CLI. Verified against a real store on this machine
|
|
7
|
+
# (2026-08-24): ~/.copilot/session-store.db, schema_version 3, one session.
|
|
8
|
+
#
|
|
9
|
+
# This store has MOVED since tokentelemetry's parser was written against
|
|
10
|
+
# it: that reads ~/.copilot/session-state/<id>/events.jsonl, and no such
|
|
11
|
+
# file exists here. The session-state/<id>/ directory does still exist as
|
|
12
|
+
# a companion (workspace.yaml, checkpoints/, files/, research/), but the
|
|
13
|
+
# session record itself is now a row in SQLite. An adapter following the
|
|
14
|
+
# older spec would report nothing on a current install — the failure this
|
|
15
|
+
# gem's `verified_on` dates exist to make visible.
|
|
16
|
+
class Copilot < Base
|
|
17
|
+
agent :copilot
|
|
18
|
+
label "GitHub Copilot CLI"
|
|
19
|
+
documented false
|
|
20
|
+
verified_on "2026-08-24"
|
|
21
|
+
fidelity :messages
|
|
22
|
+
|
|
23
|
+
def self.reader_class = Readers::Copilot
|
|
24
|
+
|
|
25
|
+
homedir :github_copilot_cli
|
|
26
|
+
|
|
27
|
+
store :database, path: "session-store.db", format: :sqlite
|
|
28
|
+
store :session_state, dir: "session-state", format: :json, optional: true
|
|
29
|
+
|
|
30
|
+
warning "token usage is not recorded in this store: the sessions and turns tables carry " \
|
|
31
|
+
"no token or cost columns, so `usage` is nil for every Copilot session"
|
|
32
|
+
|
|
33
|
+
# created_at/updated_at are ISO 8601 strings here, not the epoch
|
|
34
|
+
# milliseconds opencode and Cursor use — verified against a real row
|
|
35
|
+
# ("2026-05-26T04:36:01.288Z").
|
|
36
|
+
SESSION_COLUMNS = "id, cwd, created_at, updated_at"
|
|
37
|
+
|
|
38
|
+
def sessions
|
|
39
|
+
db_path = primary_layer.path
|
|
40
|
+
return [].lazy unless File.exist?(db_path)
|
|
41
|
+
|
|
42
|
+
Enumerator.new do |yielder|
|
|
43
|
+
each_session_row(db_path, "SELECT #{SESSION_COLUMNS} FROM sessions") do |row|
|
|
44
|
+
yielder << build_row_session(db_path, row)
|
|
45
|
+
end
|
|
46
|
+
end.lazy
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# cwd is a real column holding a real absolute path, so filtering is a
|
|
50
|
+
# WHERE clause rather than a read-and-compare loop.
|
|
51
|
+
def sessions_for_project(dir)
|
|
52
|
+
dir = File.expand_path(dir)
|
|
53
|
+
db_path = primary_layer.path
|
|
54
|
+
return [].lazy unless File.exist?(db_path)
|
|
55
|
+
|
|
56
|
+
Enumerator.new do |yielder|
|
|
57
|
+
each_session_row(db_path, "SELECT #{SESSION_COLUMNS} FROM sessions WHERE cwd = ?", [dir]) do |row|
|
|
58
|
+
yielder << build_row_session(db_path, row)
|
|
59
|
+
end
|
|
60
|
+
end.lazy
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def project_paths
|
|
64
|
+
db_path = primary_layer.path
|
|
65
|
+
return [] unless File.exist?(db_path)
|
|
66
|
+
|
|
67
|
+
paths = []
|
|
68
|
+
each_session_row(db_path, "SELECT DISTINCT cwd FROM sessions ORDER BY cwd") do |row|
|
|
69
|
+
paths << row.first if row.first.is_a?(String)
|
|
70
|
+
end
|
|
71
|
+
paths.uniq
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_row_session(db_path, row)
|
|
77
|
+
id, cwd, created, updated = row
|
|
78
|
+
Session.new(
|
|
79
|
+
agent: self.class.agent_name, id: id, path: db_path,
|
|
80
|
+
project_path: cwd.is_a?(String) ? cwd : nil,
|
|
81
|
+
started_at: parse_time(created),
|
|
82
|
+
updated_at: parse_time(updated) || parse_time(created) || db_mtime(db_path),
|
|
83
|
+
bytes: nil, # a row in a shared database has no file size of its own
|
|
84
|
+
format: primary_layer.format, fidelity: self.class.fidelity_value
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_time(value)
|
|
89
|
+
return nil unless value.is_a?(String)
|
|
90
|
+
|
|
91
|
+
Time.iso8601(value)
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# updated_at is never nil across adapters; see opencode's db_mtime for
|
|
97
|
+
# the full reasoning. Reached only when both timestamps are unusable.
|
|
98
|
+
def db_mtime(db_path)
|
|
99
|
+
@db_mtime ||= begin
|
|
100
|
+
File.mtime(db_path)
|
|
101
|
+
rescue SystemCallError
|
|
102
|
+
Time.now
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def each_session_row(db_path, sql, params = [], &block)
|
|
107
|
+
require_sqlite!
|
|
108
|
+
db = nil
|
|
109
|
+
begin
|
|
110
|
+
db = Sqlite.open_readonly(db_path)
|
|
111
|
+
db.execute(sql, params, &block)
|
|
112
|
+
rescue SQLite3::Exception => e
|
|
113
|
+
raise UnreadableStore, "#{db_path}: #{e.message}"
|
|
114
|
+
ensure
|
|
115
|
+
db&.close
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def require_sqlite!
|
|
120
|
+
require "sqlite3"
|
|
121
|
+
rescue LoadError
|
|
122
|
+
raise MissingDependency,
|
|
123
|
+
"Copilot CLI sessions live in session-store.db (SQLite); add the sqlite3 gem to enumerate them"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Sessions
|
|
5
|
+
module Adapters
|
|
6
|
+
class Cursor < Base
|
|
7
|
+
agent :cursor
|
|
8
|
+
label "Cursor CLI"
|
|
9
|
+
documented false
|
|
10
|
+
verified_on "2026-07-21"
|
|
11
|
+
fidelity :metadata
|
|
12
|
+
|
|
13
|
+
# No env override. XDG_CONFIG_HOME was assumed here and disproved on
|
|
14
|
+
# 2026-08-04: with it set, Cursor still stored under ~/.cursor, so honouring
|
|
15
|
+
# it reported an installed agent as missing. Verified on macOS only.
|
|
16
|
+
homedir :cursor
|
|
17
|
+
|
|
18
|
+
store :chats, dir: "chats", glob: "*/*/store.db", format: :sqlite
|
|
19
|
+
store :acp_sessions, dir: "acp-sessions", format: :json, optional: true
|
|
20
|
+
|
|
21
|
+
warning "chat payloads use an undocumented blob encoding; " \
|
|
22
|
+
"reads are metadata-only until it is decoded"
|
|
23
|
+
warning "no environment override is known for Cursor; XDG_CONFIG_HOME is not honoured"
|
|
24
|
+
|
|
25
|
+
# meta.json's field names (createdAtMs, updatedAtMs, cwd) are read from
|
|
26
|
+
# design doc 8.3, itself written from a machine that had them to check
|
|
27
|
+
# against — this machine has no ~/.cursor/chats (Cursor CLI is a
|
|
28
|
+
# separate product from the Cursor editor and is simply not installed
|
|
29
|
+
# here). Gated, the same shape as pi's identical warning about its own
|
|
30
|
+
# unverified header key and cursor_ide's about its real session
|
|
31
|
+
# location below: a "here is what breaks, please act on it" report
|
|
32
|
+
# reaches only someone whose declared store actually exists.
|
|
33
|
+
#
|
|
34
|
+
# Why THIS unverified assumption specifically needs a warning, where
|
|
35
|
+
# some others might get away without one: the failure is silent and
|
|
36
|
+
# looks correct. If createdAtMs/updatedAtMs are the wrong keys,
|
|
37
|
+
# meta_time returns nil and started_at_for/updated_at_for fall back to
|
|
38
|
+
# stat.birthtime/mtime — real file timestamps, not an obviously broken
|
|
39
|
+
# value. If cwd is the wrong key, project_path_for returns nil exactly
|
|
40
|
+
# the way it correctly does for a chat that genuinely has no recorded
|
|
41
|
+
# cwd. Nothing in the output distinguishes "Cursor recorded no
|
|
42
|
+
# project" from "the gem read the wrong key" — `projects`,
|
|
43
|
+
# `du --by project`, and `sessions_for_project` all silently
|
|
44
|
+
# under-report Cursor, with no error and no implausible-looking number
|
|
45
|
+
# anywhere to notice.
|
|
46
|
+
def warnings
|
|
47
|
+
list = super
|
|
48
|
+
if primary_layer.exists?
|
|
49
|
+
list << "Cursor's meta.json field names (createdAtMs, updatedAtMs, cwd) are unverified " \
|
|
50
|
+
"against a real chat — this machine has none to check them against. If `projects` " \
|
|
51
|
+
"or `du --by project` report nothing for Cursor despite it having chats, or every " \
|
|
52
|
+
"session's started_at matches its file's own mtime exactly, those keys may be " \
|
|
53
|
+
"wrong; please open an issue with the first bytes of one real meta.json"
|
|
54
|
+
end
|
|
55
|
+
list
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# chats/<chat-id>/<uuid>/store.db — two nested ids (design doc 16 Q5), so
|
|
59
|
+
# the session id keeps both. The blob store is never opened here; the
|
|
60
|
+
# sibling meta.json is the metadata source (8.3), with stat as fallback.
|
|
61
|
+
#
|
|
62
|
+
# Pure string manipulation on `path` — File.basename/File.dirname never
|
|
63
|
+
# raise for any String input, so this hook cannot violate rule 3
|
|
64
|
+
# (build_session lets a raising hook propagate) regardless of shape. A
|
|
65
|
+
# path shallower than two segments is not reachable through this
|
|
66
|
+
# store's OWN enumeration: the glob above is "*/*/store.db", and Dir.glob's
|
|
67
|
+
# "*" never crosses a "/", so every path this adapter actually enumerates
|
|
68
|
+
# is exactly two directories deep, by construction, not by convention
|
|
69
|
+
# (see test_session_id_from_does_not_raise_for_a_shallow_path, which
|
|
70
|
+
# calls this hook directly to pin the behaviour for a caller that
|
|
71
|
+
# bypasses the glob).
|
|
72
|
+
def session_id_from(path)
|
|
73
|
+
uuid = File.basename(File.dirname(path))
|
|
74
|
+
chat = File.basename(File.dirname(File.dirname(path)))
|
|
75
|
+
"#{chat}/#{uuid}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def started_at_for(path, stat)
|
|
79
|
+
meta_time(path, "createdAtMs") || super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def updated_at_for(path, stat)
|
|
83
|
+
meta_time(path, "updatedAtMs") || super
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# cwd's presence in meta.json is not enough on its own (rule 1): the key
|
|
87
|
+
# can hold a Hash, an Integer, anything JSON allows, and project_paths'
|
|
88
|
+
# .uniq.sort raises on a non-String member. is_a?(String) is the guard,
|
|
89
|
+
# not merely a style preference.
|
|
90
|
+
def project_path_for(path)
|
|
91
|
+
cwd = meta_for(path)["cwd"]
|
|
92
|
+
cwd if cwd.is_a?(String)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Normalizes the container's TYPE, not just checked its presence (rule
|
|
98
|
+
# 2): read_json already rescues a parse failure to {}, but a
|
|
99
|
+
# meta.json that parses fine into something other than an object —
|
|
100
|
+
# an array, a bare number, null — would otherwise reach ["cwd"] or
|
|
101
|
+
# ["createdAtMs"] as a non-Hash receiver. Array#[] and Integer#[] both
|
|
102
|
+
# raise TypeError for a String key; this is Amp's project_path_for bug
|
|
103
|
+
# (design doc / task rules), one layer further down the same JSON tree.
|
|
104
|
+
# Guarding once here, rather than at each of the three call sites
|
|
105
|
+
# above, is what keeps meta_time and project_path_for simple `[]` reads
|
|
106
|
+
# instead of three repeated type checks.
|
|
107
|
+
#
|
|
108
|
+
# Unbounded and per-instance, deliberately not fixed here — but the
|
|
109
|
+
# retention mechanism is NOT "adapter instances get dropped after
|
|
110
|
+
# resolution" (an earlier version of this comment claimed that, and it
|
|
111
|
+
# is wrong): build_session passes `{ project_path_for(path) }` as
|
|
112
|
+
# Session's resolver block, and that block's `self` is THIS ADAPTER,
|
|
113
|
+
# because project_path_for is called with no explicit receiver.
|
|
114
|
+
# session.rb's UNRESOLVED handling only releases the closure
|
|
115
|
+
# (`@project_path_resolver = nil`) on a Session's FIRST #project_path
|
|
116
|
+
# call, not before — so any Session a caller keeps without ever reading
|
|
117
|
+
# project_path stays holding a live reference to the whole adapter,
|
|
118
|
+
# @meta included. `list` is exactly that caller: it never touches
|
|
119
|
+
# project_path, so every Session it returns keeps this adapter (and
|
|
120
|
+
# whatever of @meta got populated enumerating them) alive for as long
|
|
121
|
+
# as the caller holds that Session array — not just for one
|
|
122
|
+
# enumeration pass. All N sessions from one `sessions` call share the
|
|
123
|
+
# SAME adapter instance, so this is one retained @meta hash, not N
|
|
124
|
+
# copies of it.
|
|
125
|
+
#
|
|
126
|
+
# What actually keeps this acceptable is SIZE, not lifetime: measured
|
|
127
|
+
# during code review at roughly 890 bytes per parsed meta.json, ~3.5 MB
|
|
128
|
+
# retained for a 4,000-chat `list` (not independently re-measured
|
|
129
|
+
# here) — small enough to leave unbounded even though it outlives the
|
|
130
|
+
# call that built it. Growth is still bounded by
|
|
131
|
+
# CONSUMPTION the way `sessions`' laziness promises elsewhere
|
|
132
|
+
# (`.first(n)` costs n entries; an early sessions_for_project match
|
|
133
|
+
# costs less than a full sweep) — only the LIFETIME claim above was
|
|
134
|
+
# wrong, not the size-is-bounded-by-consumption one. Revisit if
|
|
135
|
+
# meta.json stops being tiny (design doc 8.3, plan follow-up 8's
|
|
136
|
+
# contrast with Amp's unbounded read_json) or a caller holds a large
|
|
137
|
+
# unresolved Session array for a long time; 3.5 MB briefly retained is
|
|
138
|
+
# not worth engineering around today.
|
|
139
|
+
def meta_for(path)
|
|
140
|
+
@meta ||= {}
|
|
141
|
+
@meta[path] ||= begin
|
|
142
|
+
data = read_json(File.join(File.dirname(path), "meta.json"))
|
|
143
|
+
data.is_a?(Hash) ? data : {}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# millis.is_a?(Numeric) alone is not enough: JSON has no Infinity/NaN
|
|
148
|
+
# literal, but a finite-looking literal can still overflow to one.
|
|
149
|
+
# createdAtMs: 1e400 parses to Float::INFINITY (verified — JSON.parse
|
|
150
|
+
# accepts exponents past Float::MAX and Ruby overflows silently rather
|
|
151
|
+
# than raising at parse time), and a plain integer literal hundreds of
|
|
152
|
+
# digits long survives is_a?(Numeric) as an exact Integer but still
|
|
153
|
+
# overflows to Infinity the moment `/ 1000.0` forces it through Float
|
|
154
|
+
# (also verified). Either way Time.at(Float::INFINITY) raises
|
|
155
|
+
# FloatDomainError, uncaught, which is rule 3's failure mode — one
|
|
156
|
+
# adapter's bad timestamp taking down every agent's listing. The guard
|
|
157
|
+
# therefore checks the DIVISION's result, not just the input: a merely
|
|
158
|
+
# huge-but-finite value (an absurd but real millisecond count) is left
|
|
159
|
+
# alone and produces an absurd-but-real Time, same as a negative one
|
|
160
|
+
# produces a pre-1970 Time — neither raises, so neither is special-cased.
|
|
161
|
+
# A value that survives both guards but is still enormous (createdAtMs:
|
|
162
|
+
# 10**300, say) renders as a Time whose #to_s is hundreds of characters
|
|
163
|
+
# long — confirmed nothing here raises for it, so it is purely a
|
|
164
|
+
# rendering consequence for `list`'s column widths (Task 10) to bound,
|
|
165
|
+
# not a robustness gap this adapter needs to close.
|
|
166
|
+
def meta_time(path, key)
|
|
167
|
+
millis = meta_for(path)[key]
|
|
168
|
+
return nil unless millis.is_a?(Numeric)
|
|
169
|
+
|
|
170
|
+
seconds = millis / 1000.0
|
|
171
|
+
Time.at(seconds) if seconds.finite?
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|