agent_sessions 0.3.0 → 0.3.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +71 -3
  4. data/doc/Agent/Sessions/Adapters/Amp.md +91 -0
  5. data/doc/Agent/Sessions/Adapters/Base.md +198 -0
  6. data/doc/Agent/Sessions/Adapters/Claude.md +63 -0
  7. data/doc/Agent/Sessions/Adapters/Codex.md +79 -0
  8. data/doc/Agent/Sessions/Adapters/Copilot.md +38 -0
  9. data/doc/Agent/Sessions/Adapters/Cursor.md +54 -0
  10. data/doc/Agent/Sessions/Adapters/CursorIde.md +49 -0
  11. data/doc/Agent/Sessions/Adapters/Enumeration.md +135 -0
  12. data/doc/Agent/Sessions/Adapters/Gemini.md +67 -0
  13. data/doc/Agent/Sessions/Adapters/Grok.md +64 -0
  14. data/doc/Agent/Sessions/Adapters/Opencode.md +93 -0
  15. data/doc/Agent/Sessions/Adapters/Pi.md +137 -0
  16. data/doc/Agent/Sessions/Adapters/Qwen.md +32 -0
  17. data/doc/Agent/Sessions/Adapters.md +5 -0
  18. data/doc/Agent/Sessions/Audit/Finding.md +27 -0
  19. data/doc/Agent/Sessions/Audit.md +21 -0
  20. data/doc/Agent/Sessions/CLI.md +36 -0
  21. data/doc/Agent/Sessions/Check.md +27 -0
  22. data/doc/Agent/Sessions/Compaction.md +23 -0
  23. data/doc/Agent/Sessions/EnvOverride.md +19 -0
  24. data/doc/Agent/Sessions/Error.md +6 -0
  25. data/doc/Agent/Sessions/HomeExpansion.md +11 -0
  26. data/doc/Agent/Sessions/Location.md +60 -0
  27. data/doc/Agent/Sessions/Message.md +59 -0
  28. data/doc/Agent/Sessions/MissingDependency.md +6 -0
  29. data/doc/Agent/Sessions/Node.md +24 -0
  30. data/doc/Agent/Sessions/Part.md +41 -0
  31. data/doc/Agent/Sessions/Readers/Amp.md +41 -0
  32. data/doc/Agent/Sessions/Readers/Base.md +91 -0
  33. data/doc/Agent/Sessions/Readers/Claude.md +77 -0
  34. data/doc/Agent/Sessions/Readers/Codex.md +81 -0
  35. data/doc/Agent/Sessions/Readers/Copilot.md +31 -0
  36. data/doc/Agent/Sessions/Readers/Gemini.md +38 -0
  37. data/doc/Agent/Sessions/Readers/Grok.md +45 -0
  38. data/doc/Agent/Sessions/Readers/Opencode.md +37 -0
  39. data/doc/Agent/Sessions/Readers/Pi.md +38 -0
  40. data/doc/Agent/Sessions/Readers/Qwen.md +30 -0
  41. data/doc/Agent/Sessions/Readers.md +5 -0
  42. data/doc/Agent/Sessions/Session.md +73 -0
  43. data/doc/Agent/Sessions/Sqlite.md +43 -0
  44. data/doc/Agent/Sessions/Store.md +57 -0
  45. data/doc/Agent/Sessions/UnknownAgent.md +6 -0
  46. data/doc/Agent/Sessions/UnreadableStore.md +6 -0
  47. data/doc/Agent/Sessions/UnsupportedFormat.md +6 -0
  48. data/doc/Agent/Sessions/Usage.md +56 -0
  49. data/doc/Agent/Sessions.md +177 -0
  50. data/doc/Agent.md +5 -0
  51. data/doc/CHANGELOG.md +69 -0
  52. data/doc/README.md +162 -0
  53. data/doc/index.csv +316 -0
  54. data/exe/agent-sessions +0 -2
  55. data/lib/agent/sessions/adapters/cursor.rb +3 -3
  56. data/lib/agent/sessions/cli.rb +2 -2
  57. data/lib/agent/sessions/message.rb +1 -2
  58. data/lib/agent/sessions/part.rb +1 -2
  59. data/lib/agent/sessions/version.rb +1 -1
  60. data/llm.txt +177 -0
  61. metadata +54 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9c351be12ac0d3201532322115c21d9277bb54e7d1d7d7b6e6e2c373ba793a9
4
- data.tar.gz: 1ecb97c0d890c97542dafea00b6b8bd39b02f7427c7ad5c8627d69f2886ff1f7
3
+ metadata.gz: 963cada8c8c93c253b1e09647a64a210b1f1e009b84e064b38e5aa39d20853af
4
+ data.tar.gz: 3a2c3b727bf7710e8be93f52370fe5710898a6b12abc458135826c32a0a97178
5
5
  SHA512:
6
- metadata.gz: f963abdeb2b16d53fc32bd098abeabc4f461bda5d229b960322b3513f06719136f933e77a6a2a2428a9b655d0a9e01946c16eb8de606b7e5f8acae2517d28f1c
7
- data.tar.gz: 1f82162c07217a3175459d54301d5cb5417c18a35a42a68cda9efeaddba7276c84b666cbb6265c70089cf9a65fef55ea17de877699c4bbc26ba34424d9ee8371
6
+ metadata.gz: 2a947ed4f5ffe8890d0d4785048c88218857f113325656ea5be28c34761d2178f8d08a3a661adcf8d22bd096e3d53470e9234642ac734f2ee8a5a81be5030f46
7
+ data.tar.gz: ff29e6acb8c4bde8c8aabfa7775c9b1fc3de945419f21922ff87d7272bd5e258b8d98a5c7df102fa5564ba5aff6a50c437dd9e1ce10a2321e37d7f08ef6cd512
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.3.1 (2026-08-26)
2
+
3
+ - Add tracked Markdown YARD documentation and a consolidated `llm.txt`, package both with the gem, and validate every local documentation link
4
+ - Add a deterministic, warning-free documentation generator and a fail-fast `bin/prepare_release` workflow that tests, rebuilds documentation, refreshes `llm.txt`, and builds without publishing
5
+ - Verify the supported Ruby floor and current release in CI on Ruby 3.2 and 4.0.1, and harden gem builds with a Git-independent manifest plus isolated installation and CLI checks
6
+ - Strengthen adapter conformance coverage and remove order dependence from the affected tests and fixtures
7
+ - Keep YARD tooling development-only; runtime dependencies remain `agent_homedir` and `zeitwerk`
8
+
1
9
  ## 0.3.0 (2026-08-25)
2
10
 
3
11
  - Breaking: the Ruby namespace is now `Agent::Sessions`; `AgentSessions` is removed with no alias. `require "agent_sessions"` still works through the shim and defines the new namespace
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Where do AI coding agents store their session logs? This gem knows.
4
4
 
5
+ ![agent-sessions demo](docs/demos/demo.gif)
6
+
5
7
  Resolves session store paths for Claude Code, Codex CLI, Cursor (CLI and IDE), Amp CLI, opencode, pi, Gemini CLI, GitHub Copilot CLI, Qwen Code, and Grok Build. Verifies those paths against disk. Audits whether plaintext transcripts sit inside anything that syncs.
6
8
 
7
9
  Read-only by design. Runtime dependencies: `agent_homedir` and `zeitwerk`.
@@ -12,12 +14,22 @@ Two adapters, Qwen Code and Grok Build, are marked provisional: no such store ex
12
14
 
13
15
  ## Installation
14
16
 
15
- Add to your Gemfile:
17
+ Use Ruby 3.2 or newer.
18
+
19
+ Install the CLI directly:
20
+
21
+ ```sh
22
+ gem install agent_sessions
23
+ ```
24
+
25
+ Or add to your application's Gemfile:
16
26
 
17
27
  ```ruby
18
28
  gem "agent_sessions"
19
29
  ```
20
30
 
31
+ Enumerating the SQLite-backed agents — opencode, Cursor IDE, and GitHub Copilot CLI — needs the optional `sqlite3` gem. Every other adapter works without it.
32
+
21
33
  ## Quick start
22
34
 
23
35
  ```sh
@@ -30,8 +42,32 @@ agent-sessions du --by project
30
42
 
31
43
  Add `--json` to `where`, `doctor`, `audit`, `list`, or `du` for machine-readable output. `du --by project` is the one command in the gem that is not stat-only: resolving a project name pays one bounded read per session for the file-based agents (opencode answers from its own SQL query instead, so it pays nothing extra).
32
44
 
45
+ ## Supported agents
46
+
47
+ Use the bare name in CLI arguments and the symbol in Ruby calls:
48
+
49
+ | Agent | CLI | Ruby |
50
+ | --- | --- | --- |
51
+ | Claude Code | `claude` | `:claude` |
52
+ | Codex CLI | `codex` | `:codex` |
53
+ | Cursor CLI | `cursor` | `:cursor` |
54
+ | Cursor IDE | `cursor_ide` | `:cursor_ide` |
55
+ | Amp CLI | `amp` | `:amp` |
56
+ | opencode | `opencode` | `:opencode` |
57
+ | pi | `pi` | `:pi` |
58
+ | Gemini CLI | `gemini` | `:gemini` |
59
+ | GitHub Copilot CLI | `copilot` | `:copilot` |
60
+ | Qwen Code | `qwen` | `:qwen` |
61
+ | Grok Build | `grok` | `:grok` |
62
+
63
+ Ruby callers passing any other symbol get `Agent::Sessions::UnknownAgent`; its message lists the valid names. The CLI catches that error, prints its message, and exits with status 1.
64
+
33
65
  ## Ruby API
34
66
 
67
+ ![agent_sessions Ruby API demo](docs/demos/demo-ruby.gif)
68
+
69
+ Browse the [generated API documentation](https://github.com/lucianghinda/agent_sessions/blob/main/doc/Agent/Sessions.md) or the consolidated [`llm.txt`](https://github.com/lucianghinda/agent_sessions/blob/main/llm.txt) reference.
70
+
35
71
  ```ruby
36
72
  require "agent_sessions" # compatibility shim for Agent::Sessions
37
73
 
@@ -70,7 +106,7 @@ Agent::Sessions.for_project(Dir.pwd) # every agent's sessions for one
70
106
  Agent::Sessions.projects(:codex) # distinct recorded project paths (reads headers)
71
107
  ```
72
108
 
73
- Enumerating the SQLite-backed agents — opencode, Cursor IDE and Copilot CLI — needs the optional `sqlite3` gem. Readers exist for opencode and Copilot CLI; Cursor IDE remains metadata-only. The other adapters do not need that additional optional dependency.
109
+ Among the SQLite-backed agents, readers exist for opencode and Copilot CLI; Cursor IDE remains metadata-only.
74
110
 
75
111
  Read a session's messages and token usage (Claude, Codex, Amp, opencode, pi, Gemini CLI, Copilot CLI, Qwen and Grok):
76
112
 
@@ -86,13 +122,45 @@ reader.usage&.input # disjoint buckets: input, output,
86
122
 
87
123
  `Session#bytes` is what that session occupies on disk, not just its transcript. Claude Code writes a sidecar directory beside each transcript — `<id>/subagents/`, `<id>/tool-results/` — and those bytes belong to the session that produced them, which is why `du` and `audit` agree on the same store.
88
124
 
125
+ ### Errors
126
+
127
+ Every domain-specific error the gem raises descends from `Agent::Sessions::Error`:
128
+
129
+ - Catch `Agent::Sessions::UnknownAgent` for a name outside the supported-agents table; the message lists valid names.
130
+ - Catch `Agent::Sessions::MissingDependency` when a SQLite-backed agent is enumerated without the optional `sqlite3` gem installed.
131
+ - Catch `Agent::Sessions::UnsupportedFormat` when `read` is called on a session whose format has no reader (Cursor CLI and Cursor IDE).
132
+ - Catch `Agent::Sessions::UnreadableStore` when a store exists but cannot be opened.
133
+
89
134
  ## Roadmap
90
135
 
91
136
  - 0.2: enumerate sessions, map them to projects (`list`, `du`)
92
137
  - **0.3 (this release):** read and normalize messages; Ruby API renamed to `Agent::Sessions` while `require "agent_sessions"` stays as the compatibility shim, and base-dir resolution delegates to `agent_homedir`
93
- - 0.4: Cursor IDE remains metadata-only; the opencode and Copilot CLI SQLite readers landed in 0.3
138
+ - 0.4: a reader for Cursor IDE, which is metadata-only today
94
139
  - 0.5: `export` with secret redaction
95
140
 
141
+ ## Contributing
142
+
143
+ Activate Ruby 3.2 or newer.
144
+
145
+ Run the tests before sending a change:
146
+
147
+ ```sh
148
+ bundle install
149
+ bundle exec rake test
150
+ ```
151
+
152
+ Before a release, run:
153
+
154
+ ```sh
155
+ bin/prepare_release
156
+ ```
157
+
158
+ It runs the test suite, regenerates the API documentation and `llm.txt`, and builds the gem without publishing it.
159
+
160
+ ## License
161
+
162
+ MIT
163
+
96
164
  ## History
97
165
 
98
166
  View the [changelog](CHANGELOG.md).
@@ -0,0 +1,91 @@
1
+ # Class Agent::Sessions::Adapters::Amp <a id="class-Agent-Sessions-Adapters-Amp"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [Agent::Sessions::Adapters::Base](Base.md) |
6
+ | **Defined in** | lib/agent/sessions/adapters/amp.rb |
7
+
8
+ ## Public Class Methods
9
+ ### `reader_class()` <a id="method-c-reader_class"></a> <a id="reader_class-class_method"></a>
10
+ Not documented.
11
+
12
+ ## Public Instance Methods
13
+ ### `project_path_for(path)` <a id="method-i-project_path_for"></a> <a id="project_path_for-instance_method"></a>
14
+ env.initial.trees is the array a workspace's roots live in (plural —the format
15
+ is shaped for more than one). Verified 2026-08-05: the one real thread on this
16
+ machine carries exactly one tree, so `trees` is reported and an actual
17
+ multi-root thread's other roots are silently unreachable through
18
+ sessions_for_project/project_paths — see the warning below, which is where a
19
+ user who hits that finds out, since nobody who has looked at real data has hit
20
+ it yet. Reporting only the first is still the right call: Session#project_path
21
+ is a single String, not a list, so supporting every root would be a data-model
22
+ change this task does not make, and the failure mode is a false NEGATIVE (a
23
+ real root that never matches), never a false positive.
24
+
25
+ started_at deliberately does NOT read `created` from this same JSON: that
26
+ would turn every session's stat-only listing into a content read, not just
27
+ project_path's already-deferred one, breaking the stat-only guarantee
28
+ `sessions` makes for every adapter (Base's class comment on `sessions`) — and
29
+ it cannot be fixed by memoizing the parse across both hooks, either:
30
+ build_session computes started_at_for eagerly at construction and defers only
31
+ project_path through Session's resolver block, so a memo would always be cold
32
+ when started_at runs. The ordering never reverses without a Session/Base
33
+ change, which is out of scope here. Base's stat.birthtime fallback stays in
34
+ effect, nil-on-Linux limitation and all (see rule 3) — that gap is not
35
+ Amp-specific; Claude's started_at has the identical gap from the identical
36
+ fallback. Worth revisiting if a caller needs started_at at all on a filesystem
37
+ without birthtime; nothing today does (Task 10 sorts by updated_at, matching
38
+ Codex's own note on the same trade-off).
39
+
40
+ URI.parse, not `uri.delete_prefix("file://")`: the naive strip mishandles the
41
+ authority-component form <code>file://localhost/Users/...</code> (it would
42
+ leave a leading "localhost/" in the path), which URI.parse's #path strips
43
+ correctly by design. That fix is only net-positive once its own new failure
44
+ modes are covered, and a partial mirror's JSON is exactly the kind of data
45
+ that can be present-but-wrong at every step, not merely absent (rule 1, one
46
+ level up: the CONTAINER at each step needs checking, not just the leaf):
47
+ - opaque form "file:relative/x" parses with scheme "file" but
48
+ #path nil — decode_uri_component(nil) raises NoMethodError.
49
+ - "file:", "file://", "file://localhost" all parse to path "" —
50
+ truthy, so left unchecked it becomes an empty-string project
51
+ instead of the unknown-project nil an empty path actually means.
52
+ - "file://nas/share" (a real host, e.g. a network share) parses to
53
+ path "/share" with host "nas" — a location this machine cannot
54
+ read as a local directory, silently rejected here rather than
55
+ reported as if it were one.
56
+ - a trailing slash ("file:///Users/you/app/") survives decoding
57
+ unchanged and would never equal a caller's expanded path.
58
+ - an unescaped character (a literal space) makes URI.parse itself
59
+ raise URI::InvalidURIError — file DATA, not an adapter bug, so
60
+ that raise is rescued rather than left to propagate and take
61
+ every agent's listing down with it (rule 2). The rescue is
62
+ scoped to the URI.parse call alone, not the whole method: Codex
63
+ and pi both wrap only Time.new the same way, for the same
64
+ reason — a method-scoped rescue would just as readily swallow a
65
+ raise from a genuine adapter bug above it. decode_uri_component
66
+ needs no rescue of its own: it only ever substitutes /%\h\h/, and
67
+ URI.parse has already rejected any malformed escape by the time
68
+ its #path reaches that call (verified against %FF%FE, %C3%28,
69
+ %80 — none raise).
70
+
71
+ Every JSON level below is unwrapped by hand and type-checked, rather than one
72
+ #dig("env", "initial", "trees", 0, "uri") call: #dig raises TypeError the
73
+ moment an intermediate value is present but not itself diggable (a String
74
+ "env", a top-level Array, "trees" holding a String instead of an Array...),
75
+ which is the same present-but-wrong risk as above, one level higher. Each
76
+ <code>[]</code>/<code>.first</code> below is only called once its receiver has
77
+ already been confirmed the right shape, so none of them can raise on their
78
+ own. uri.is_a?(String) is kept even though URI.parse's own rescue above would
79
+ also catch every non-String value JSON can produce here (Hash, Array, Integer,
80
+ Float, true, false, nil all raise URI::InvalidURIError when handed to
81
+ URI.parse, verified 2026-08-05) — the guard is not load-bearing against those
82
+ specific values today, but it keeps this method's contract with URI.parse
83
+ explicit rather than resting on an undocumented side effect of what that call
84
+ happens to do with the wrong type, and it matches every sibling adapter's
85
+ convention of checking a value's type before use.
86
+
87
+ ### `warnings()` <a id="method-i-warnings"></a> <a id="warnings-instance_method"></a>
88
+ Gated, unlike the warning above. That one is a permanent property of the agent
89
+ and is worth reading before adopting the gem; this one is a "here is what
90
+ breaks, please send this back" report, and the plan's rule for those is that
91
+ they reach only people who can act on them. Same gate pi uses.
@@ -0,0 +1,198 @@
1
+ # Class Agent::Sessions::Adapters::Base <a id="class-Agent-Sessions-Adapters-Base"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | Object |
6
+ | **Includes** | [Agent::Sessions::Adapters::Enumeration](Enumeration.md), [Agent::Sessions::HomeExpansion](../HomeExpansion.md) |
7
+ | **Defined in** | lib/agent/sessions/adapters/base.rb |
8
+
9
+ Base class for every agent adapter. Subclass this directly, never another
10
+ adapter: the DSL keeps its configuration in singleton instance variables,
11
+ which Ruby does not carry down a second level of inheritance, so a subclass of
12
+ a subclass would silently declare nothing.
13
+
14
+ An instance memoizes what it resolves. Build a new instance per resolution
15
+ rather than reusing one across changes to the env hash.
16
+
17
+ What lives here is Layer 1: where a store is, what it declares, and whether
18
+ disk agrees. Turning that store into sessions is Layer 2 and lives in
19
+ Enumeration, included below — the two halves met at 460 lines in one class and
20
+ were split before Layer 3 readers could make it three.
21
+
22
+ ## Constants
23
+ ### `FIDELITIES` <a id="constant-FIDELITIES"></a> <a id="FIDELITIES-constant"></a>
24
+ Not documented.
25
+
26
+ ## Attributes
27
+ ### `agent_name` [R] <a id="attribute-c-agent_name"></a> <a id="agent_name-class_method"></a>
28
+ Returns the value of attribute agent_name.
29
+
30
+ ### `declared_warnings` [R] <a id="attribute-c-declared_warnings"></a> <a id="declared_warnings-class_method"></a>
31
+ Returns the value of attribute declared_warnings.
32
+
33
+ ### `documented_value` [R] <a id="attribute-c-documented_value"></a> <a id="documented_value-class_method"></a>
34
+ Returns the value of attribute documented_value.
35
+
36
+ ### `label_text` [R] <a id="attribute-c-label_text"></a> <a id="label_text-class_method"></a>
37
+ Returns the value of attribute label_text.
38
+
39
+ ### `verified_on_date` [R] <a id="attribute-c-verified_on_date"></a> <a id="verified_on_date-class_method"></a>
40
+ Returns the value of attribute verified_on_date.
41
+
42
+ ## Public Class Methods
43
+ ### `fidelity_value()` <a id="method-c-fidelity_value"></a> <a id="fidelity_value-class_method"></a>
44
+ :unsupported is the honest default for an adapter that has not declared what a
45
+ reader could reconstruct from its format.
46
+
47
+ ### `homedir_config()` <a id="method-c-homedir_config"></a> <a id="homedir_config-class_method"></a>
48
+ Not documented.
49
+
50
+ ### `reader_class()` <a id="method-c-reader_class"></a> <a id="reader_class-class_method"></a>
51
+ The Layer 3 reader for this agent, or nil while it has none. nil is what makes
52
+ Agent::Sessions.read raise UnsupportedFormat instead of handing back a reader
53
+ that quietly yields nothing.
54
+
55
+ ### `store_configs()` <a id="method-c-store_configs"></a> <a id="store_configs-class_method"></a>
56
+ Not documented.
57
+
58
+ ## Public Instance Methods
59
+ ### `base_dir()` <a id="method-i-base_dir"></a> <a id="base_dir-instance_method"></a>
60
+ Not documented.
61
+
62
+ ### `bytes_for(_path, stat)` <a id="method-i-bytes_for"></a> <a id="bytes_for-instance_method"></a>
63
+ Bytes this session occupies on disk. The transcript alone for a store that
64
+ keeps one file per session; an adapter whose agent writes sidecar files beside
65
+ the transcript overrides this and adds them. Like the two time hooks it takes
66
+ the stat the enumerator already holds, so the common case still costs nothing
67
+ beyond the syscall already made.
68
+
69
+ An override runs EAGERLY for every session, so it carries build_session's
70
+ constraint: it must not raise on an unreadable path, or one bad sidecar takes
71
+ down the whole listing rather than its own row.
72
+
73
+ ### `encode_project(_dir)` <a id="method-i-encode_project"></a> <a id="encode_project-instance_method"></a>
74
+ nil means this adapter has no directory-name fallback rule. Used only by
75
+ sessions_for_project, and only for a session whose own recorded cwd could not
76
+ be read. When overridden: dir arrives pre-expanded here from
77
+ sessions_for_project (File.expand_path), which is the precondition an override
78
+ may rely on — a direct caller must pass an absolute, expanded path itself, or
79
+ the encoding is nonsense ("app", "~/app", and a trailing slash all encode
80
+ differently from the canonical form real project directories were named from).
81
+
82
+ ### `initialize(env: ENV)` <a id="method-i-initialize"></a> <a id="initialize-instance_method"></a>
83
+ - **@return** [Base] a new instance of Base
84
+
85
+ ### `locate()` <a id="method-i-locate"></a> <a id="locate-instance_method"></a>
86
+ Not documented.
87
+
88
+ ### `project_dir_name(path)` <a id="method-i-project_dir_name"></a> <a id="project_dir_name-instance_method"></a>
89
+ The directory whose name the encoding must match, when sessions_for_project
90
+ falls back to it. Overridable: not every store puts the encoded project
91
+ directly above the session file — cursor_ide nests
92
+ projects/<name>/agent-transcripts/*, where the immediate parent is
93
+ agent-transcripts and matching it would find nothing, silently.
94
+
95
+ ### `project_path_for(_path)` <a id="method-i-project_path_for"></a> <a id="project_path_for-instance_method"></a>
96
+ nil means the project is unknown for this session. Adapters override with a
97
+ bounded read of their own metadata; Base cannot guess.
98
+
99
+ ### `project_paths()` <a id="method-i-project_paths"></a> <a id="project_paths-instance_method"></a>
100
+ Distinct recorded project paths, sorted. This is the read-everything direction
101
+ (design doc section 7): the encodings cannot be reversed, so the recorded cwd
102
+ inside each file is the only reliable source. Sessions whose project cannot be
103
+ determined are excluded, not returned as nil.
104
+
105
+ Sorted rather than left in glob order because a stable order is what makes
106
+ `projects` output diffable and `du --by project` deterministic — and adapters
107
+ answering from a database would otherwise impose their own.
108
+
109
+ ### `retention()` <a id="method-i-retention"></a> <a id="retention-instance_method"></a>
110
+ Not documented.
111
+
112
+ ### `retention_source()` <a id="method-i-retention_source"></a> <a id="retention_source-instance_method"></a>
113
+ Not documented.
114
+
115
+ ### `session_id_from(path)` <a id="method-i-session_id_from"></a> <a id="session_id_from-instance_method"></a>
116
+ --- Layer 2 hooks, overridable per adapter ---
117
+
118
+ ### `sessions()` <a id="method-i-sessions"></a> <a id="sessions-instance_method"></a>
119
+ Lazily enumerates the primary store. Each consumed session costs one stat plus
120
+ filename parsing — never a content read. project_path is the exception and
121
+ pays for itself on first access.
122
+
123
+ A store the gem has no layout for is refused rather than reported empty:
124
+ Location#enumerable? exists precisely so "nothing here to enumerate" and
125
+ "enumerated, found none" stay distinguishable, and silently returning no
126
+ sessions is this gem's worst failure mode.
127
+
128
+ ### `sessions_for_project(dir)` <a id="method-i-sessions_for_project"></a> <a id="sessions_for_project-instance_method"></a>
129
+ Match by RECORDED cwd, exact, per session (design doc section 7, revised
130
+ 2026-08-05 — the third design for this method, kept honest here because the
131
+ next reader deserves to know why it is not "cheap"). The first two designs
132
+ were built and disproved against a real store, not in theory:
133
+
134
+ 1. Directory-name matching (the original design) assumed a
135
+ session's parent directory equals encode(its own recorded cwd).
136
+ A project rename breaks that: the agent keeps writing under the
137
+ OLD encoded directory, so two directories can hold live sessions
138
+ for the SAME current cwd, and name-only matching silently
139
+ dropped the stale one — false negatives, the failure mode this
140
+ gem treats as worst (decision 11).
141
+
142
+ 2. One-read-per-directory sampling (the first fix for #1) assumed
143
+ sessions within a directory share a cwd, to keep the match
144
+ sublinear. Reading a real renamed project's stale directory
145
+ disproved that: two of its three sessions had been resumed after
146
+ the rename and recorded the NEW cwd; the third was never resumed
147
+ and still recorded the OLD one. Sampling one session and
148
+ applying its verdict to the whole directory is wrong in BOTH
149
+ directions on the same store — it invented a false positive
150
+ here, and a different glob order would just as easily have
151
+ reproduced #1's false negative for that same directory.
152
+ Approximate cwd resolution doesn't make the error smaller; it
153
+ just moves where it lands.
154
+
155
+ Measured cost of reading every session instead of sampling: 0.17 ms per
156
+ session (68 real Claude sessions, full sweep, 0.012s total) — 0.7s
157
+ extrapolated to a 4,000-session store. That is what the sampling complexity
158
+ was buying, and it is not a trade worth making: the enumerator is already
159
+ lazy, so a caller taking first(n) never pays for sessions it never asked
160
+ about, and even the worst case (every session checked, no match) stays under a
161
+ second on a store two orders of magnitude larger than anything observed.
162
+
163
+ A session whose own cwd cannot be read (the scan gave up, the file is
164
+ unreadable, the adapter declares no reader) falls back to comparing ITS OWN
165
+ directory's name against the encoding, when the adapter declares one — this is
166
+ the only thing encode_project still buys: it keeps a session with an
167
+ unreadable header from becoming invisible, without resolving an unknown
168
+ project for every other session that happens to share its directory.
169
+
170
+ ### `started_at_for(_path, stat)` <a id="method-i-started_at_for"></a> <a id="started_at_for-instance_method"></a>
171
+ Both time hooks take the stat the enumerator already holds, so a session still
172
+ costs one syscall. path is passed for adapters that answer from a sibling
173
+ metadata file instead.
174
+
175
+ nil beats a wrong guess when the filesystem cannot answer at all — and it says
176
+ so through ENOSYS/EPERM from statx as often as NotImplementedError.
177
+
178
+ ### `updated_at_for(_path, stat)` <a id="method-i-updated_at_for"></a> <a id="updated_at_for-instance_method"></a>
179
+ Not documented.
180
+
181
+ ### `verify()` <a id="method-i-verify"></a> <a id="verify-instance_method"></a>
182
+ Checks every declared store against disk. The design doc says each adapter
183
+ declares its own checks, and each one does: its store_configs decide what is
184
+ looked for and whether an absence is a failure or drift. Content-level checks
185
+ (first record type, encoding round-trip) need file reads and wait for Layer 3.
186
+ An adapter that needs its own can override this and call super.
187
+
188
+ The skip gate is the same signal Store#installed? uses: any declared store
189
+ exists. It is deliberately NOT base-dir existence — ~/.cursor is created by
190
+ the Cursor editor with no agent store in it (observed 2026-08-05), and the old
191
+ gate made doctor report FAIL while `where` said "(not installed)". A missing
192
+ store proves nothing on its own (never used? layout moved? the gem cannot
193
+ tell), so :fail is reserved for the one case with evidence: some store exists,
194
+ proving the agent records data here, while a required one is absent — the
195
+ layout-moved signature.
196
+
197
+ ### `warnings()` <a id="method-i-warnings"></a> <a id="warnings-instance_method"></a>
198
+ Not documented.
@@ -0,0 +1,63 @@
1
+ # Class Agent::Sessions::Adapters::Claude <a id="class-Agent-Sessions-Adapters-Claude"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [Agent::Sessions::Adapters::Base](Base.md) |
6
+ | **Defined in** | lib/agent/sessions/adapters/claude.rb |
7
+
8
+ ## Constants
9
+ ### `DEFAULT_CLEANUP_PERIOD_DAYS` <a id="constant-DEFAULT_CLEANUP_PERIOD_DAYS"></a> <a id="DEFAULT_CLEANUP_PERIOD_DAYS-constant"></a>
10
+ Not documented.
11
+
12
+ ## Public Class Methods
13
+ ### `reader_class()` <a id="method-c-reader_class"></a> <a id="reader_class-class_method"></a>
14
+ Not documented.
15
+
16
+ ## Public Instance Methods
17
+ ### `bytes_for(path, stat)` <a id="method-i-bytes_for"></a> <a id="bytes_for-instance_method"></a>
18
+ Claude Code writes a directory beside each transcript, named after the session
19
+ id with the extension dropped: subagents/ holds the transcripts of agents this
20
+ session spawned, tool-results/ holds tool output too large to inline. Those
21
+ bytes are this session's, and until they were counted `du` reported 122.1 MB
22
+ for a store `audit` reported 173.0 MB for — 71% — because audit sums the store
23
+ directory whole while du sums sessions. Two commands, one directory, a 29%
24
+ disagreement.
25
+
26
+ Measured over 128 real sessions on 2026-08-10: 0.002 ms per session when there
27
+ is no sidecar (one stat, the common case on a fresh install) and 0.080 ms when
28
+ there is. That is under half what project_path's content read costs, and
29
+ unlike project_path this cannot be deferred —bytes is eager, and a
30
+ lazily-corrected byte total would leave `list` printing one number while `du`
31
+ summed another.
32
+
33
+ ### `encode_project(dir)` <a id="method-i-encode_project"></a> <a id="encode_project-instance_method"></a>
34
+ Every non-alphanumeric character becomes "-" (design doc section 7). dir must
35
+ already be absolute and expanded — sessions_for_project guarantees that; a
36
+ direct caller passing "app", "~/app", or a trailing slash gets a nonsense
37
+ encoding (see Base#encode_project). Verified against real project directories
38
+ on 2026-08-05: /Users/dev/.local -> -Users-dev--local
39
+
40
+ ### `project_path_for(path)` <a id="method-i-project_path_for"></a> <a id="project_path_for-instance_method"></a>
41
+ cwd is NOT on line 1. Real sessions open with a kebab-case preamble (ai-title,
42
+ agent-name, mode, permission-mode) followed by a variable-length run of
43
+ file-history-snapshot records — that run is what pushes the first cwd-bearing
44
+ record out further on some files, and nothing bounds its length. Observed on
45
+ this machine on 2026-08-05: line 3 (19 files), line 4 (48 files), line 9 (1
46
+ file, a longer snapshot run). limit: 25 is ~2.8x that observed maximum —
47
+ headroom for the variable-length run, not a tight fit to the common case — and
48
+ keeps this a few-KB read even on multi-GB files.
49
+
50
+ The block guards against a record that carries "cwd" but not usably: null
51
+ shadows a later valid record, and a wrong type (Integer, Hash) would otherwise
52
+ reach project_paths' .uniq.sort and raise there. scan_jsonl_for_key already
53
+ guarantees the key is present once the block accepts, so a plain fetch (no
54
+ default) is safe.
55
+
56
+ ### `retention()` <a id="method-i-retention"></a> <a id="retention-instance_method"></a>
57
+ Not documented.
58
+
59
+ ### `retention_source()` <a id="method-i-retention_source"></a> <a id="retention_source-instance_method"></a>
60
+ Not documented.
61
+
62
+ ### `warnings()` <a id="method-i-warnings"></a> <a id="warnings-instance_method"></a>
63
+ Not documented.
@@ -0,0 +1,79 @@
1
+ # Class Agent::Sessions::Adapters::Codex <a id="class-Agent-Sessions-Adapters-Codex"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [Agent::Sessions::Adapters::Base](Base.md) |
6
+ | **Defined in** | lib/agent/sessions/adapters/codex.rb |
7
+
8
+ ## Constants
9
+ ### `FILENAME` <a id="constant-FILENAME"></a> <a id="FILENAME-constant"></a>
10
+ rollout-<YYYY-MM-DDTHH-MM-SS>-<uuid>.jsonl (verified 2026-08-05 against 360
11
+ real session files on this machine — every one matched). The timestamp uses
12
+ the local clock and dashes where ISO 8601 has colons. started_at_for's comment
13
+ says what "local" costs elsewhere. The uuid group is pinned to its actual
14
+ shape (8-4-4-4-12 hex), not (.+): greedy against .jsonlz, (.+) would swallow a
15
+ sync tool's or backup's " (conflicted copy)" suffix into what looks like a
16
+ canonical id rather than falling back to the basename, where such a copy is at
17
+ least visibly non-canonical.
18
+
19
+ ## Public Class Methods
20
+ ### `reader_class()` <a id="method-c-reader_class"></a> <a id="reader_class-class_method"></a>
21
+ Not documented.
22
+
23
+ ## Public Instance Methods
24
+ ### `project_path_for(path)` <a id="method-i-project_path_for"></a> <a id="project_path_for-instance_method"></a>
25
+ Line 1 is session_meta; the cwd lives in its payload (design doc section 6 and
26
+ 8.2, verified 2026-08-05 — 360/360 real files carry a usable
27
+ session_meta/payload/cwd on line 1). limit: 3 is slack against that guarantee,
28
+ not a fit to any observed multi-line case: it tolerates a truncated or blank
29
+ first line without paying for an unbounded scan. "3" counts iterations of
30
+ File.foreach(path, "n", MAX_LINE_BYTES), not lines: a >1MB record is chunked
31
+ and each chunk is one iteration, so this is really 3MB of read headroom, not
32
+ "3 records." A future adapter copying this pattern with limit: 1 would lose
33
+ that tolerance entirely.
34
+
35
+ The predicate requires more than scan_jsonl_for_key's key-presence check can:
36
+ real sessions on this machine also carry a "payload" key on later,
37
+ non-session_meta records (turn_context observed 2026-08-05) whose payload
38
+ itself carries "cwd" — a presence-only scan would stop at whichever comes
39
+ first, right only by coincidence. Requiring type == "session_meta" pins the
40
+ read to the one documented source of truth (design doc 8.2), and requiring a
41
+ Hash payload with a String cwd stops a malformed record (payload not a Hash,
42
+ or cwd not a String) from permanently shadowing a later, usable session_meta
43
+ or reaching project_paths' .uniq.sort with the wrong type.
44
+
45
+ ### `session_id_from(path)` <a id="method-i-session_id_from"></a> <a id="session_id_from-instance_method"></a>
46
+ Not documented.
47
+
48
+ ### `sessions()` <a id="method-i-sessions"></a> <a id="sessions-instance_method"></a>
49
+ Codex writes rollout files to two stores, and Base enumerates only the primary
50
+ one. An archived session is still a session — a real one was found outside the
51
+ sessions/ glob on 2026-08-10 — and a session the gem does not report is the
52
+ silent under-reporting this design treats as its worst failure mode. Every
53
+ filename hook below applies unchanged: the archived files carry the same
54
+ rollout-<timestamp>-<uuid>.jsonl name.
55
+
56
+ `super` first, so the guard it raises when the primary store has no known
57
+ layout still fires, and so live sessions come out before archived ones.
58
+ Chained rather than concatenated to keep the result lazy: a caller taking
59
+ first(n) must not stat an archived file it never asked about.
60
+
61
+ ### `started_at_for(path, stat)` <a id="method-i-started_at_for"></a> <a id="started_at_for-instance_method"></a>
62
+ The digit groups accept 00-99 each, which Time.new does not: month 13, minute
63
+ 60, and similar out-of-range values raise ArgumentError rather than being
64
+ normalized. That is file DATA, not an adapter bug, so it must not cross the
65
+ line build_session draws between the two (a raising hook is meant to surface
66
+ as a programming error) — one such filename among many good ones would
67
+ otherwise take sessions, project_paths, and sessions_for_project down to zero
68
+ for every agent, not just Codex.
69
+
70
+ Local, not UTC: session_meta's own "timestamp" field is UTC and agrees with
71
+ this to within 1s across all 360 real files here, but a machine whose TZ
72
+ changed, or a store copied from another machine, would make this off by the
73
+ offset delta while Claude's birthtime-based started_at stays an absolute
74
+ instant. Harmless today because Task 10 sorts sessions by updated_at, not
75
+ started_at. The rescue wraps Time.new alone rather than the whole method. A
76
+ method-scoped rescue would also swallow an ArgumentError from a future
77
+ signature change — the commonest Ruby programming error — and silently return
78
+ birthtime for every Codex session: a plausible-looking wrong started_at with
79
+ no signal, which is worse than a crash.
@@ -0,0 +1,38 @@
1
+ # Class Agent::Sessions::Adapters::Copilot <a id="class-Agent-Sessions-Adapters-Copilot"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | [Agent::Sessions::Adapters::Base](Base.md) |
6
+ | **Defined in** | lib/agent/sessions/adapters/copilot.rb |
7
+
8
+ GitHub Copilot CLI. Verified against a real store on this machine
9
+ (2026-08-24): ~/.copilot/session-store.db, schema_version 3, one session.
10
+
11
+ This store has MOVED since tokentelemetry's parser was written against it:
12
+ that reads ~/.copilot/session-state/<id>/events.jsonl, and no such file exists
13
+ here. The session-state/<id>/ directory does still exist as a companion
14
+ (workspace.yaml, checkpoints/, files/, research/), but the session record
15
+ itself is now a row in SQLite. An adapter following the older spec would
16
+ report nothing on a current install — the failure this gem's `verified_on`
17
+ dates exist to make visible.
18
+
19
+ ## Constants
20
+ ### `SESSION_COLUMNS` <a id="constant-SESSION_COLUMNS"></a> <a id="SESSION_COLUMNS-constant"></a>
21
+ created_at/updated_at are ISO 8601 strings here, not the epoch milliseconds
22
+ opencode and Cursor use — verified against a real row
23
+ ("2026-05-26T04:36:01.288Z").
24
+
25
+ ## Public Class Methods
26
+ ### `reader_class()` <a id="method-c-reader_class"></a> <a id="reader_class-class_method"></a>
27
+ Not documented.
28
+
29
+ ## Public Instance Methods
30
+ ### `project_paths()` <a id="method-i-project_paths"></a> <a id="project_paths-instance_method"></a>
31
+ Not documented.
32
+
33
+ ### `sessions()` <a id="method-i-sessions"></a> <a id="sessions-instance_method"></a>
34
+ Not documented.
35
+
36
+ ### `sessions_for_project(dir)` <a id="method-i-sessions_for_project"></a> <a id="sessions_for_project-instance_method"></a>
37
+ cwd is a real column holding a real absolute path, so filtering is a WHERE
38
+ clause rather than a read-and-compare loop.