agent_sessions 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 963cada8c8c93c253b1e09647a64a210b1f1e009b84e064b38e5aa39d20853af
4
- data.tar.gz: 3a2c3b727bf7710e8be93f52370fe5710898a6b12abc458135826c32a0a97178
3
+ metadata.gz: 205ddd97d2f2ebb3156e0b38fdc32cf202bbac5efff802a4b98da6e7e00322dd
4
+ data.tar.gz: dfddd74259b6d7f3beccd20f6f6b1d3bffb4415ad4803ca6e2445b1ac8e98cde
5
5
  SHA512:
6
- metadata.gz: 2a947ed4f5ffe8890d0d4785048c88218857f113325656ea5be28c34761d2178f8d08a3a661adcf8d22bd096e3d53470e9234642ac734f2ee8a5a81be5030f46
7
- data.tar.gz: ff29e6acb8c4bde8c8aabfa7775c9b1fc3de945419f21922ff87d7272bd5e258b8d98a5c7df102fa5564ba5aff6a50c437dd9e1ce10a2321e37d7f08ef6cd512
6
+ metadata.gz: 3d769697b7feff74f07602b6ffbdb24b7c7a2d5f0343deccebe58fddeb288d608b824ee07cb556f28b436281dd6bbbc3d7f772b6fd6ced41889a921f7dc113ed
7
+ data.tar.gz: 0d64231c4294e4a97a0b5a3ebb191ad8555f0d1c6982a78e531613c584686fe57b4a0e6a1f1418107947ccbcd313a145440c010912087ac50973706aed8a5a94
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.4.0 (2026-09-10)
2
+
3
+ - Round-trip grouping in every reader: `RoundTrip` (`index`, `messages`, `usage`, `recorded`, plus `parts`, `calls`, `roles`), `reader.each_round_trip` (streams), `reader.round_trips` (eager), `reader.round_trips_recorded?`
4
+ - Claude names its round-trip id — `message.id`, the id Anthropic's API gives one response — and is the only reader today where `round_trips_recorded?` answers true; every other reader falls back to one round trip per message
5
+ - The reappearing-id warning fires only when nothing explains the split. Measured over the 60 most recent real Claude transcripts (2026-09-10): 3,315 distinct message ids, 261 of them (7.9%) split across more than one run in 33 of the 60 files; 759 of those splits are a tool_result record, 66 a last-prompt, 12 a file-history-delta — all benign, so warning on them would put four or five lines under every real session
6
+ - Conformance suite case C9: `round_trips` must never lose or duplicate a message, across every reader
7
+ - The loop view (`Loop`, `ToolCall`, `LoopView`) and its CLI command (`show`) moved to the `agent_session_context` gem before release, so they never shipped here
8
+
1
9
  ## 0.3.1 (2026-08-26)
2
10
 
3
11
  - Add tracked Markdown YARD documentation and a consolidated `llm.txt`, package both with the gem, and validate every local documentation link
data/README.md CHANGED
@@ -2,7 +2,7 @@
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)
5
+ <img width="1200" height="620" alt="demo" src="https://github.com/user-attachments/assets/d13e7c68-0cb4-404c-bb31-d2c3b24d3522" />
6
6
 
7
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.
8
8
 
@@ -64,7 +64,7 @@ Ruby callers passing any other symbol get `Agent::Sessions::UnknownAgent`; its m
64
64
 
65
65
  ## Ruby API
66
66
 
67
- ![agent_sessions Ruby API demo](docs/demos/demo-ruby.gif)
67
+ <img width="1200" height="620" alt="demo-ruby" src="https://github.com/user-attachments/assets/a0dd54b6-d597-4dad-85ae-97ce07aba2a4" />
68
68
 
69
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
70
 
@@ -122,6 +122,22 @@ reader.usage&.input # disjoint buckets: input, output,
122
122
 
123
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.
124
124
 
125
+ ### Round trips
126
+
127
+ A round trip is one model response, however many records the store split it across:
128
+
129
+ ```ruby
130
+ reader.round_trips # eager: an Array of RoundTrip
131
+ reader.each_round_trip { |trip| ... } # streams; breaking early reads one group, not the file
132
+ reader.round_trips_recorded? # true only where the store names its own groups
133
+ ```
134
+
135
+ A `RoundTrip` carries `index`, `messages`, `usage`, and `recorded`, and derives `parts` (every part, in file order), `calls` (its `:tool_use` parts), and `roles` (the distinct roles seen, first-seen order).
136
+
137
+ Claude names its groups (`message.id`); every other reader falls back to one round trip per message, and `round_trips_recorded?` answers `false` there — "not recorded" must never read as "none".
138
+
139
+ Pairing a call with the result that answers it, and rendering a session as a loop, live in the sibling [`agent_session_context`](https://github.com/lucianghinda/agent_session_context) gem, via its `agent-session-context loop` command.
140
+
125
141
  ### Errors
126
142
 
127
143
  Every domain-specific error the gem raises descends from `Agent::Sessions::Error`:
@@ -47,6 +47,29 @@ every message to get them.
47
47
  Streams. Yields each message as it is parsed; a caller that breaks after one
48
48
  has read one record, not the file.
49
49
 
50
+ ### `each_round_trip()` <a id="method-i-each_round_trip"></a> <a id="each_round_trip-instance_method"></a>
51
+ Streams, exactly as each_message does. A group is a RUN of neighbouring
52
+ records sharing one round-trip id, not every record sharing that id wherever
53
+ it sits in the file — collecting scattered records cannot stream, and rule 3
54
+ says no code path here may assume a file fits in memory.
55
+
56
+ So an id that reappears after its run closed opens a NEW round trip rather
57
+ than reopening the old one. That is ORDINARY for Claude, not an anomaly:
58
+ Claude Code writes each tool_result immediately after the tool_use it answers,
59
+ while every content block of the one API response keeps the same message.id,
60
+ so a response making two tool calls has its records split by the result in
61
+ between. Measured over the 60 most recent real Claude transcripts on this
62
+ machine (2026-09-10): 3,315 distinct message.ids, 261 of them (7.9%) split
63
+ across more than one run, in 33 of the 60 files; 759 of the splits are a
64
+ tool_result record, 66 a last-prompt, 12 a file-history-delta.
65
+
66
+ Hence the warning fires only where NOTHING answered a tool between the two
67
+ runs — the case that is genuinely unexplained and would mean the format
68
+ drifted. Warning on the benign split would put four or five lines under every
69
+ real session's loop view, which teaches a caller that these warnings are
70
+ noise.
71
+ - **@yield** [build_round_trip(index + 1, open_messages, open_id)]
72
+
50
73
  ### `fidelity()` <a id="method-i-fidelity"></a> <a id="fidelity-instance_method"></a>
51
74
  Not documented.
52
75
 
@@ -63,6 +86,14 @@ True where the local file is not the whole story — Amp, whose server holds the
63
86
  canonical copy. Overridden there, false everywhere else.
64
87
  - **@return** [Boolean]
65
88
 
89
+ ### `round_trips()` <a id="method-i-round_trips"></a> <a id="round_trips-instance_method"></a>
90
+ Not documented.
91
+
92
+ ### `round_trips_recorded?()` <a id="method-i-round_trips_recorded-3F"></a> <a id="round_trips_recorded?-instance_method"></a>
93
+ False here: most stores are an append-only list of records and no format names
94
+ which of them belong to one model response.
95
+ - **@return** [Boolean]
96
+
66
97
  ### `tree()` <a id="method-i-tree"></a> <a id="tree-instance_method"></a>
67
98
  The conversation as roots and their continuations, for an agent that records
68
99
  parent links. Unlike every other method here this cannot stream — a tree is
@@ -56,6 +56,9 @@ tree from.
56
56
  ### `initialize(session, resolve_spills: true, **rest)` <a id="method-i-initialize"></a> <a id="initialize-instance_method"></a>
57
57
  - **@return** [Claude] a new instance of Claude
58
58
 
59
+ ### `round_trips_recorded?()` <a id="method-i-round_trips_recorded-3F"></a> <a id="round_trips_recorded?-instance_method"></a>
60
+ - **@return** [Boolean]
61
+
59
62
  ### `subagents()` <a id="method-i-subagents"></a> <a id="subagents-instance_method"></a>
60
63
  The transcripts of agents this session spawned, as readers of their own.
61
64
  Exposed rather than inlined, per design doc 8.1: a subagent's turns are not
@@ -0,0 +1,57 @@
1
+ # Class Agent::Sessions::RoundTrip <a id="class-Agent-Sessions-RoundTrip"></a>
2
+
3
+ | | |
4
+ | --- | --- |
5
+ | **Inherits** | Data |
6
+ | **Defined in** | lib/agent/sessions/round_trip.rb |
7
+
8
+ One model response, grouped from however many records a format split it
9
+ across. Claude Code writes one record PER CONTENT BLOCK of an API response: in
10
+ one real transcript on this machine, 260 assistant records share only 124
11
+ message.id values, so a caller counting records over- counts what the session
12
+ actually did by roughly two to one. A round trip is the unit that actually
13
+ happened.
14
+
15
+ `recorded` is the load-bearing field. False means this group was ASSUMED — the
16
+ format names no round-trip id, so grouping fell back to one message per round
17
+ trip — never "this session has none". Same rule tree()/branching? already
18
+ enforce for parent links: "not recorded" must never read as "recorded and
19
+ empty".
20
+
21
+ A round trip deliberately carries NO tool results: a result can arrive long
22
+ after its call, and grouping streams one record at a time, so pairing a call
23
+ with its eventual result is a separate, eager layer (Agent::Sessions::Loop,
24
+ landing next) rather than this one's job.
25
+
26
+ ## Attributes
27
+ ### `index` [R] <a id="attribute-i-index"></a> <a id="index-instance_method"></a>
28
+ Returns the value of attribute index
29
+ - **@return** [Object] the current value of index
30
+
31
+ ### `messages` [R] <a id="attribute-i-messages"></a> <a id="messages-instance_method"></a>
32
+ Returns the value of attribute messages
33
+ - **@return** [Object] the current value of messages
34
+
35
+ ### `recorded` [R] <a id="attribute-i-recorded"></a> <a id="recorded-instance_method"></a>
36
+ Returns the value of attribute recorded
37
+ - **@return** [Object] the current value of recorded
38
+
39
+ ### `usage` [R] <a id="attribute-i-usage"></a> <a id="usage-instance_method"></a>
40
+ Returns the value of attribute usage
41
+ - **@return** [Object] the current value of usage
42
+
43
+ ## Public Instance Methods
44
+ ### `at()` <a id="method-i-at"></a> <a id="at-instance_method"></a>
45
+ The first recorded Time among the messages, or nil when none carried one.
46
+
47
+ ### `calls()` <a id="method-i-calls"></a> <a id="calls-instance_method"></a>
48
+ The :tool_use parts only, [] when this round trip asked for no tool.
49
+
50
+ ### `model()` <a id="method-i-model"></a> <a id="model-instance_method"></a>
51
+ The first recorded model String, or nil.
52
+
53
+ ### `parts()` <a id="method-i-parts"></a> <a id="parts-instance_method"></a>
54
+ Every part of every message, in file order.
55
+
56
+ ### `roles()` <a id="method-i-roles"></a> <a id="roles-instance_method"></a>
57
+ The distinct roles in this group, in first-seen order.
@@ -2,7 +2,7 @@
2
2
 
3
3
  | | |
4
4
  | --- | --- |
5
- | **Defined in** | lib/agent/sessions.rb, lib/agent/sessions/cli.rb, lib/agent/sessions/node.rb, lib/agent/sessions/part.rb, lib/agent/sessions/audit.rb, lib/agent/sessions/check.rb, lib/agent/sessions/error.rb, lib/agent/sessions/store.rb, lib/agent/sessions/usage.rb, lib/agent/sessions/sqlite.rb, lib/agent/sessions/message.rb, lib/agent/sessions/session.rb, lib/agent/sessions/version.rb, lib/agent/sessions/location.rb, lib/agent/sessions/compaction.rb, lib/agent/sessions/readers/pi.rb, lib/agent/sessions/adapters/pi.rb, lib/agent/sessions/readers/amp.rb, lib/agent/sessions/adapters/amp.rb, lib/agent/sessions/env_override.rb, lib/agent/sessions/readers/base.rb, lib/agent/sessions/readers/grok.rb, lib/agent/sessions/readers/qwen.rb, lib/agent/sessions/adapters/base.rb, lib/agent/sessions/adapters/grok.rb, lib/agent/sessions/adapters/qwen.rb, lib/agent/sessions/readers/codex.rb, lib/agent/sessions/unknown_agent.rb, lib/agent/sessions/adapters/codex.rb, lib/agent/sessions/home_expansion.rb, lib/agent/sessions/readers/claude.rb, lib/agent/sessions/readers/gemini.rb, lib/agent/sessions/adapters/claude.rb, lib/agent/sessions/adapters/cursor.rb, lib/agent/sessions/adapters/gemini.rb, lib/agent/sessions/readers/copilot.rb, lib/agent/sessions/adapters/copilot.rb, lib/agent/sessions/readers/opencode.rb, lib/agent/sessions/unreadable_store.rb, lib/agent/sessions/adapters/opencode.rb, lib/agent/sessions/missing_dependency.rb, lib/agent/sessions/unsupported_format.rb, lib/agent/sessions/adapters/cursor_ide.rb, lib/agent/sessions/adapters/enumeration.rb |
5
+ | **Defined in** | lib/agent/sessions.rb, lib/agent/sessions/cli.rb, lib/agent/sessions/node.rb, lib/agent/sessions/part.rb, lib/agent/sessions/audit.rb, lib/agent/sessions/check.rb, lib/agent/sessions/error.rb, lib/agent/sessions/store.rb, lib/agent/sessions/usage.rb, lib/agent/sessions/sqlite.rb, lib/agent/sessions/message.rb, lib/agent/sessions/session.rb, lib/agent/sessions/version.rb, lib/agent/sessions/location.rb, lib/agent/sessions/compaction.rb, lib/agent/sessions/readers/pi.rb, lib/agent/sessions/round_trip.rb, lib/agent/sessions/adapters/pi.rb, lib/agent/sessions/readers/amp.rb, lib/agent/sessions/adapters/amp.rb, lib/agent/sessions/env_override.rb, lib/agent/sessions/readers/base.rb, lib/agent/sessions/readers/grok.rb, lib/agent/sessions/readers/qwen.rb, lib/agent/sessions/adapters/base.rb, lib/agent/sessions/adapters/grok.rb, lib/agent/sessions/adapters/qwen.rb, lib/agent/sessions/readers/codex.rb, lib/agent/sessions/unknown_agent.rb, lib/agent/sessions/adapters/codex.rb, lib/agent/sessions/home_expansion.rb, lib/agent/sessions/readers/claude.rb, lib/agent/sessions/readers/gemini.rb, lib/agent/sessions/adapters/claude.rb, lib/agent/sessions/adapters/cursor.rb, lib/agent/sessions/adapters/gemini.rb, lib/agent/sessions/readers/copilot.rb, lib/agent/sessions/adapters/copilot.rb, lib/agent/sessions/readers/opencode.rb, lib/agent/sessions/unreadable_store.rb, lib/agent/sessions/adapters/opencode.rb, lib/agent/sessions/missing_dependency.rb, lib/agent/sessions/unsupported_format.rb, lib/agent/sessions/adapters/cursor_ide.rb, lib/agent/sessions/adapters/enumeration.rb |
6
6
 
7
7
  ## Constants
8
8
  ### `LOADER` <a id="constant-LOADER"></a> <a id="LOADER-constant"></a>
@@ -168,6 +168,7 @@ Not documented.
168
168
  - [Sessions/Readers/Opencode.md](Sessions/Readers/Opencode.md)
169
169
  - [Sessions/Readers/Pi.md](Sessions/Readers/Pi.md)
170
170
  - [Sessions/Readers/Qwen.md](Sessions/Readers/Qwen.md)
171
+ - [Sessions/RoundTrip.md](Sessions/RoundTrip.md)
171
172
  - [Sessions/Session.md](Sessions/Session.md)
172
173
  - [Sessions/Sqlite.md](Sessions/Sqlite.md)
173
174
  - [Sessions/Store.md](Sessions/Store.md)
data/doc/Agent.md CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  | | |
4
4
  | --- | --- |
5
- | **Defined in** | lib/agent/sessions.rb, lib/agent/sessions/cli.rb, lib/agent/sessions/node.rb, lib/agent/sessions/part.rb, lib/agent/sessions/audit.rb, lib/agent/sessions/check.rb, lib/agent/sessions/error.rb, lib/agent/sessions/store.rb, lib/agent/sessions/usage.rb, lib/agent/sessions/sqlite.rb, lib/agent/sessions/message.rb, lib/agent/sessions/session.rb, lib/agent/sessions/version.rb, lib/agent/sessions/location.rb, lib/agent/sessions/compaction.rb, lib/agent/sessions/readers/pi.rb, lib/agent/sessions/adapters/pi.rb, lib/agent/sessions/readers/amp.rb, lib/agent/sessions/adapters/amp.rb, lib/agent/sessions/env_override.rb, lib/agent/sessions/readers/base.rb, lib/agent/sessions/readers/grok.rb, lib/agent/sessions/readers/qwen.rb, lib/agent/sessions/adapters/base.rb, lib/agent/sessions/adapters/grok.rb, lib/agent/sessions/adapters/qwen.rb, lib/agent/sessions/readers/codex.rb, lib/agent/sessions/unknown_agent.rb, lib/agent/sessions/adapters/codex.rb, lib/agent/sessions/home_expansion.rb, lib/agent/sessions/readers/claude.rb, lib/agent/sessions/readers/gemini.rb, lib/agent/sessions/adapters/claude.rb, lib/agent/sessions/adapters/cursor.rb, lib/agent/sessions/adapters/gemini.rb, lib/agent/sessions/readers/copilot.rb, lib/agent/sessions/adapters/copilot.rb, lib/agent/sessions/readers/opencode.rb, lib/agent/sessions/unreadable_store.rb, lib/agent/sessions/adapters/opencode.rb, lib/agent/sessions/missing_dependency.rb, lib/agent/sessions/unsupported_format.rb, lib/agent/sessions/adapters/cursor_ide.rb, lib/agent/sessions/adapters/enumeration.rb |
5
+ | **Defined in** | lib/agent/sessions.rb, lib/agent/sessions/cli.rb, lib/agent/sessions/node.rb, lib/agent/sessions/part.rb, lib/agent/sessions/audit.rb, lib/agent/sessions/check.rb, lib/agent/sessions/error.rb, lib/agent/sessions/store.rb, lib/agent/sessions/usage.rb, lib/agent/sessions/sqlite.rb, lib/agent/sessions/message.rb, lib/agent/sessions/session.rb, lib/agent/sessions/version.rb, lib/agent/sessions/location.rb, lib/agent/sessions/compaction.rb, lib/agent/sessions/readers/pi.rb, lib/agent/sessions/round_trip.rb, lib/agent/sessions/adapters/pi.rb, lib/agent/sessions/readers/amp.rb, lib/agent/sessions/adapters/amp.rb, lib/agent/sessions/env_override.rb, lib/agent/sessions/readers/base.rb, lib/agent/sessions/readers/grok.rb, lib/agent/sessions/readers/qwen.rb, lib/agent/sessions/adapters/base.rb, lib/agent/sessions/adapters/grok.rb, lib/agent/sessions/adapters/qwen.rb, lib/agent/sessions/readers/codex.rb, lib/agent/sessions/unknown_agent.rb, lib/agent/sessions/adapters/codex.rb, lib/agent/sessions/home_expansion.rb, lib/agent/sessions/readers/claude.rb, lib/agent/sessions/readers/gemini.rb, lib/agent/sessions/adapters/claude.rb, lib/agent/sessions/adapters/cursor.rb, lib/agent/sessions/adapters/gemini.rb, lib/agent/sessions/readers/copilot.rb, lib/agent/sessions/adapters/copilot.rb, lib/agent/sessions/readers/opencode.rb, lib/agent/sessions/unreadable_store.rb, lib/agent/sessions/adapters/opencode.rb, lib/agent/sessions/missing_dependency.rb, lib/agent/sessions/unsupported_format.rb, lib/agent/sessions/adapters/cursor_ide.rb, lib/agent/sessions/adapters/enumeration.rb |
data/doc/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.4.0 (2026-09-10)
2
+
3
+ - Round-trip grouping in every reader: `RoundTrip` (`index`, `messages`, `usage`, `recorded`, plus `parts`, `calls`, `roles`), `reader.each_round_trip` (streams), `reader.round_trips` (eager), `reader.round_trips_recorded?`
4
+ - Claude names its round-trip id — `message.id`, the id Anthropic's API gives one response — and is the only reader today where `round_trips_recorded?` answers true; every other reader falls back to one round trip per message
5
+ - The reappearing-id warning fires only when nothing explains the split. Measured over the 60 most recent real Claude transcripts (2026-09-10): 3,315 distinct message ids, 261 of them (7.9%) split across more than one run in 33 of the 60 files; 759 of those splits are a tool_result record, 66 a last-prompt, 12 a file-history-delta — all benign, so warning on them would put four or five lines under every real session
6
+ - Conformance suite case C9: `round_trips` must never lose or duplicate a message, across every reader
7
+ - The loop view (`Loop`, `ToolCall`, `LoopView`) and its CLI command (`show`) moved to the `agent_session_context` gem before release, so they never shipped here
8
+
1
9
  ## 0.3.1 (2026-08-26)
2
10
 
3
11
  - Add tracked Markdown YARD documentation and a consolidated `llm.txt`, package both with the gem, and validate every local documentation link
data/doc/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
+ <img width="1200" height="620" alt="demo" src="https://github.com/user-attachments/assets/d13e7c68-0cb4-404c-bb31-d2c3b24d3522" />
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`.
@@ -62,6 +64,8 @@ Ruby callers passing any other symbol get `Agent::Sessions::UnknownAgent`; its m
62
64
 
63
65
  ## Ruby API
64
66
 
67
+ <img width="1200" height="620" alt="demo-ruby" src="https://github.com/user-attachments/assets/a0dd54b6-d597-4dad-85ae-97ce07aba2a4" />
68
+
65
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.
66
70
 
67
71
  ```ruby
@@ -118,6 +122,22 @@ reader.usage&.input # disjoint buckets: input, output,
118
122
 
119
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.
120
124
 
125
+ ### Round trips
126
+
127
+ A round trip is one model response, however many records the store split it across:
128
+
129
+ ```ruby
130
+ reader.round_trips # eager: an Array of RoundTrip
131
+ reader.each_round_trip { |trip| ... } # streams; breaking early reads one group, not the file
132
+ reader.round_trips_recorded? # true only where the store names its own groups
133
+ ```
134
+
135
+ A `RoundTrip` carries `index`, `messages`, `usage`, and `recorded`, and derives `parts` (every part, in file order), `calls` (its `:tool_use` parts), and `roles` (the distinct roles seen, first-seen order).
136
+
137
+ Claude names its groups (`message.id`); every other reader falls back to one round trip per message, and `round_trips_recorded?` answers `false` there — "not recorded" must never read as "none".
138
+
139
+ Pairing a call with the result that answers it, and rendering a session as a loop, live in the sibling [`agent_session_context`](https://github.com/lucianghinda/agent_session_context) gem, via its `agent-session-context loop` command.
140
+
121
141
  ### Errors
122
142
 
123
143
  Every domain-specific error the gem raises descends from `Agent::Sessions::Error`:
data/doc/index.csv CHANGED
@@ -139,6 +139,16 @@ Agent::Sessions::Readers::Pi,Class,Agent/Sessions/Readers/Pi.md
139
139
  Agent::Sessions::Readers::Pi.NON_MESSAGE_TYPES,Constant,Agent/Sessions/Readers/Pi.md#constant-NON_MESSAGE_TYPES
140
140
  Agent::Sessions::Readers::Pi.ROLES,Constant,Agent/Sessions/Readers/Pi.md#constant-ROLES
141
141
  Agent::Sessions::Readers::Pi.usage,Method,Agent/Sessions/Readers/Pi.md#method-i-usage
142
+ Agent::Sessions::RoundTrip,Class,Agent/Sessions/RoundTrip.md
143
+ Agent::Sessions::RoundTrip.at,Method,Agent/Sessions/RoundTrip.md#method-i-at
144
+ Agent::Sessions::RoundTrip.calls,Method,Agent/Sessions/RoundTrip.md#method-i-calls
145
+ Agent::Sessions::RoundTrip.model,Method,Agent/Sessions/RoundTrip.md#method-i-model
146
+ Agent::Sessions::RoundTrip.parts,Method,Agent/Sessions/RoundTrip.md#method-i-parts
147
+ Agent::Sessions::RoundTrip.roles,Method,Agent/Sessions/RoundTrip.md#method-i-roles
148
+ Agent::Sessions::RoundTrip.index,Attribute,Agent/Sessions/RoundTrip.md#attribute-i-index
149
+ Agent::Sessions::RoundTrip.messages,Attribute,Agent/Sessions/RoundTrip.md#attribute-i-messages
150
+ Agent::Sessions::RoundTrip.recorded,Attribute,Agent/Sessions/RoundTrip.md#attribute-i-recorded
151
+ Agent::Sessions::RoundTrip.usage,Attribute,Agent/Sessions/RoundTrip.md#attribute-i-usage
142
152
  Agent::Sessions::Adapters::Pi,Class,Agent/Sessions/Adapters/Pi.md
143
153
  Agent::Sessions::Adapters::Pi.FILENAME,Constant,Agent/Sessions/Adapters/Pi.md#constant-FILENAME
144
154
  Agent::Sessions::Adapters::Pi.encode_project,Method,Agent/Sessions/Adapters/Pi.md#method-i-encode_project
@@ -165,10 +175,13 @@ Agent::Sessions::Readers::Base.MAX_RECORD_BYTES,Constant,Agent/Sessions/Readers/
165
175
  Agent::Sessions::Readers::Base.branching?,Method,Agent/Sessions/Readers/Base.md#method-i-branching-3F
166
176
  Agent::Sessions::Readers::Base.compactions,Method,Agent/Sessions/Readers/Base.md#method-i-compactions
167
177
  Agent::Sessions::Readers::Base.each_message,Method,Agent/Sessions/Readers/Base.md#method-i-each_message
178
+ Agent::Sessions::Readers::Base.each_round_trip,Method,Agent/Sessions/Readers/Base.md#method-i-each_round_trip
168
179
  Agent::Sessions::Readers::Base.fidelity,Method,Agent/Sessions/Readers/Base.md#method-i-fidelity
169
180
  Agent::Sessions::Readers::Base.initialize,Method,Agent/Sessions/Readers/Base.md#method-i-initialize
170
181
  Agent::Sessions::Readers::Base.messages,Method,Agent/Sessions/Readers/Base.md#method-i-messages
171
182
  Agent::Sessions::Readers::Base.partial?,Method,Agent/Sessions/Readers/Base.md#method-i-partial-3F
183
+ Agent::Sessions::Readers::Base.round_trips,Method,Agent/Sessions/Readers/Base.md#method-i-round_trips
184
+ Agent::Sessions::Readers::Base.round_trips_recorded?,Method,Agent/Sessions/Readers/Base.md#method-i-round_trips_recorded-3F
172
185
  Agent::Sessions::Readers::Base.tree,Method,Agent/Sessions/Readers/Base.md#method-i-tree
173
186
  Agent::Sessions::Readers::Base.usage,Method,Agent/Sessions/Readers/Base.md#method-i-usage
174
187
  Agent::Sessions::Readers::Base.warnings,Method,Agent/Sessions/Readers/Base.md#method-i-warnings
@@ -252,6 +265,7 @@ Agent::Sessions::Readers::Claude.SPILL,Constant,Agent/Sessions/Readers/Claude.md
252
265
  Agent::Sessions::Readers::Claude.MAX_SPILL_BYTES,Constant,Agent/Sessions/Readers/Claude.md#constant-MAX_SPILL_BYTES
253
266
  Agent::Sessions::Readers::Claude.branching?,Method,Agent/Sessions/Readers/Claude.md#method-i-branching-3F
254
267
  Agent::Sessions::Readers::Claude.initialize,Method,Agent/Sessions/Readers/Claude.md#method-i-initialize
268
+ Agent::Sessions::Readers::Claude.round_trips_recorded?,Method,Agent/Sessions/Readers/Claude.md#method-i-round_trips_recorded-3F
255
269
  Agent::Sessions::Readers::Claude.subagents,Method,Agent/Sessions/Readers/Claude.md#method-i-subagents
256
270
  Agent::Sessions::Readers::Claude.usage,Method,Agent/Sessions/Readers/Claude.md#method-i-usage
257
271
  Agent::Sessions::Readers::Gemini,Class,Agent/Sessions/Readers/Gemini.md
@@ -60,6 +60,75 @@ module Agent
60
60
  # `each_message` is what a 2.6 GB file requires.
61
61
  def messages = each_message.to_a
62
62
 
63
+ # Streams, exactly as each_message does. A group is a RUN of
64
+ # neighbouring records sharing one round-trip id, not every record
65
+ # sharing that id wherever it sits in the file — collecting scattered
66
+ # records cannot stream, and rule 3 says no code path here may assume
67
+ # a file fits in memory.
68
+ #
69
+ # So an id that reappears after its run closed opens a NEW round trip
70
+ # rather than reopening the old one. That is ORDINARY for Claude, not
71
+ # an anomaly: Claude Code writes each tool_result immediately after
72
+ # the tool_use it answers, while every content block of the one API
73
+ # response keeps the same message.id, so a response making two tool
74
+ # calls has its records split by the result in between. Measured over
75
+ # the 60 most recent real Claude transcripts on this machine
76
+ # (2026-09-10): 3,315 distinct message.ids, 261 of them (7.9%) split
77
+ # across more than one run, in 33 of the 60 files; 759 of the splits
78
+ # are a tool_result record, 66 a last-prompt, 12 a file-history-delta.
79
+ #
80
+ # Hence the warning fires only where NOTHING answered a tool between
81
+ # the two runs — the case that is genuinely unexplained and would mean
82
+ # the format drifted. Warning on the benign split would put four or
83
+ # five lines under every real session's loop view, which teaches a
84
+ # caller that these warnings are noise.
85
+ def each_round_trip
86
+ return enum_for(:each_round_trip) unless block_given?
87
+
88
+ open_messages = []
89
+ open_id = nil
90
+ closed_ids = {}
91
+ answers_seen = 0
92
+ index = 0
93
+
94
+ each_message do |message|
95
+ id = round_trip_id_for(message.raw)
96
+
97
+ if id && id == open_id
98
+ open_messages << message
99
+ answers_seen += 1 if answers_a_tool?(message)
100
+ next
101
+ end
102
+
103
+ if open_messages.any?
104
+ index += 1
105
+ yield build_round_trip(index, open_messages, open_id)
106
+ end
107
+ # What this id's run closed at, counted in tool answers seen so
108
+ # far. Comparing that count against the count now is what
109
+ # separates "a tool result split one response" from "this id came
110
+ # back for a reason nothing here explains".
111
+ closed_ids[open_id] = answers_seen if open_id
112
+
113
+ if id && closed_ids[id] == answers_seen
114
+ warn_about("round-trip id #{id} reappears after its run closed with no tool answered " \
115
+ "in between; grouped as a new round trip")
116
+ end
117
+
118
+ open_messages = [message]
119
+ open_id = id
120
+ answers_seen += 1 if answers_a_tool?(message)
121
+ end
122
+
123
+ yield build_round_trip(index + 1, open_messages, open_id) if open_messages.any?
124
+ end
125
+
126
+ def round_trips = each_round_trip.to_a
127
+
128
+ # False here: most stores are an append-only list of records and no
129
+ # format names which of them belong to one model response.
130
+ def round_trips_recorded? = false
131
+
63
132
  # Whether this agent records which turn each turn followed. False here:
64
133
  # most stores are an append-only list and a tree would have to be invented.
65
134
  def branching? = false
@@ -117,6 +186,32 @@ module Agent
117
186
  def node_id_for(_record) = nil
118
187
  def parent_id_for(_record) = nil
119
188
 
189
+ # nil means "this format records no round-trip id". Same shape of
190
+ # hook as node_id_for/parent_id_for: an agent says WHERE its id
191
+ # lives, never how the grouping is assembled.
192
+ def round_trip_id_for(_record) = nil
193
+
194
+ # Whether this message is a tool answering a call. Used only to tell a
195
+ # benign split of one response's records from an unexplained one.
196
+ def answers_a_tool?(message) = message.parts.any? { |part| part.type == :tool_result }
197
+
198
+ def build_round_trip(index, messages, id)
199
+ RoundTrip.new(index: index, messages: messages, usage: usage_for(messages), recorded: !id.nil?)
200
+ end
201
+
202
+ # NEVER a sum. Claude repeats one API response's usage byte-for-byte
203
+ # on each record of that response (94 of 124 message ids in one real
204
+ # transcript), so summing the group would report roughly double what
205
+ # the vendor billed.
206
+ def usage_for(messages)
207
+ found = messages.filter_map(&:usage)
208
+ return nil if found.empty?
209
+ return found.first if found.uniq.size == 1
210
+
211
+ warn_about("round trip records disagree on usage; reporting the first")
212
+ found.first
213
+ end
214
+
120
215
  # Two passes over one session. The first records every uuid-bearing
121
216
  # record's parent and which of them became messages; the second links
122
217
  # each message to the nearest ANCESTOR that is also a message.
@@ -52,6 +52,8 @@ module Agent
52
52
  # trustworthy enough to build a tree from.
53
53
  def branching? = true
54
54
 
55
+ def round_trips_recorded? = true
56
+
55
57
  def initialize(session, resolve_spills: true, **rest)
56
58
  super(session, **rest)
57
59
  @resolve_spills = resolve_spills
@@ -112,6 +114,14 @@ module Agent
112
114
  def node_id_for(record) = record["uuid"]
113
115
  def parent_id_for(record) = record["parentUuid"]
114
116
 
117
+ # message.id is the id Anthropic's API gives one response; Claude
118
+ # Code writes one record PER CONTENT BLOCK, all repeating it. The
119
+ # reader already dedups session usage by exactly this id (usage,
120
+ # above), so the fact is known and only needs exposing. A record
121
+ # with no message.id (a user turn, an event) yields nil and becomes
122
+ # its own assumed round trip.
123
+ def round_trip_id_for(record) = record.dig("message", "id")
124
+
115
125
  def message_for(record, line_number)
116
126
  type = record["type"]
117
127
  return nil if NON_MESSAGE_TYPES.include?(type)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module Sessions
5
+ # One model response, grouped from however many records a format split it
6
+ # across. Claude Code writes one record PER CONTENT BLOCK of an API
7
+ # response: in one real transcript on this machine, 260 assistant records
8
+ # share only 124 message.id values, so a caller counting records over-
9
+ # counts what the session actually did by roughly two to one. A round
10
+ # trip is the unit that actually happened.
11
+ #
12
+ # `recorded` is the load-bearing field. False means this group was
13
+ # ASSUMED — the format names no round-trip id, so grouping fell back to
14
+ # one message per round trip — never "this session has none". Same rule
15
+ # tree()/branching? already enforce for parent links: "not recorded"
16
+ # must never read as "recorded and empty".
17
+ #
18
+ # A round trip deliberately carries NO tool results: a result can arrive
19
+ # long after its call, and grouping streams one record at a time, so
20
+ # pairing a call with its eventual result is a separate, eager layer
21
+ # (Agent::Sessions::Loop, landing next) rather than this one's job.
22
+ RoundTrip = Data.define(:index, :messages, :usage, :recorded) do
23
+ # Every part of every message, in file order.
24
+ def parts = messages.flat_map(&:parts)
25
+
26
+ # The :tool_use parts only, [] when this round trip asked for no tool.
27
+ def calls = parts.select { |part| part.type == :tool_use }
28
+
29
+ # The distinct roles in this group, in first-seen order.
30
+ def roles = messages.map(&:role).uniq
31
+
32
+ # The first recorded Time among the messages, or nil when none carried one.
33
+ def at = messages.filter_map(&:at).first
34
+
35
+ # The first recorded model String, or nil.
36
+ def model = messages.filter_map(&:model).first
37
+ end
38
+ end
39
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Agent
4
4
  module Sessions
5
- VERSION = "0.3.1"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
data/llm.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  | | |
4
4
  | --- | --- |
5
- | **Defined in** | lib/agent/sessions.rb, lib/agent/sessions/cli.rb, lib/agent/sessions/node.rb, lib/agent/sessions/part.rb, lib/agent/sessions/audit.rb, lib/agent/sessions/check.rb, lib/agent/sessions/error.rb, lib/agent/sessions/store.rb, lib/agent/sessions/usage.rb, lib/agent/sessions/sqlite.rb, lib/agent/sessions/message.rb, lib/agent/sessions/session.rb, lib/agent/sessions/version.rb, lib/agent/sessions/location.rb, lib/agent/sessions/compaction.rb, lib/agent/sessions/readers/pi.rb, lib/agent/sessions/adapters/pi.rb, lib/agent/sessions/readers/amp.rb, lib/agent/sessions/adapters/amp.rb, lib/agent/sessions/env_override.rb, lib/agent/sessions/readers/base.rb, lib/agent/sessions/readers/grok.rb, lib/agent/sessions/readers/qwen.rb, lib/agent/sessions/adapters/base.rb, lib/agent/sessions/adapters/grok.rb, lib/agent/sessions/adapters/qwen.rb, lib/agent/sessions/readers/codex.rb, lib/agent/sessions/unknown_agent.rb, lib/agent/sessions/adapters/codex.rb, lib/agent/sessions/home_expansion.rb, lib/agent/sessions/readers/claude.rb, lib/agent/sessions/readers/gemini.rb, lib/agent/sessions/adapters/claude.rb, lib/agent/sessions/adapters/cursor.rb, lib/agent/sessions/adapters/gemini.rb, lib/agent/sessions/readers/copilot.rb, lib/agent/sessions/adapters/copilot.rb, lib/agent/sessions/readers/opencode.rb, lib/agent/sessions/unreadable_store.rb, lib/agent/sessions/adapters/opencode.rb, lib/agent/sessions/missing_dependency.rb, lib/agent/sessions/unsupported_format.rb, lib/agent/sessions/adapters/cursor_ide.rb, lib/agent/sessions/adapters/enumeration.rb |
5
+ | **Defined in** | lib/agent/sessions.rb, lib/agent/sessions/cli.rb, lib/agent/sessions/node.rb, lib/agent/sessions/part.rb, lib/agent/sessions/audit.rb, lib/agent/sessions/check.rb, lib/agent/sessions/error.rb, lib/agent/sessions/store.rb, lib/agent/sessions/usage.rb, lib/agent/sessions/sqlite.rb, lib/agent/sessions/message.rb, lib/agent/sessions/session.rb, lib/agent/sessions/version.rb, lib/agent/sessions/location.rb, lib/agent/sessions/compaction.rb, lib/agent/sessions/readers/pi.rb, lib/agent/sessions/round_trip.rb, lib/agent/sessions/adapters/pi.rb, lib/agent/sessions/readers/amp.rb, lib/agent/sessions/adapters/amp.rb, lib/agent/sessions/env_override.rb, lib/agent/sessions/readers/base.rb, lib/agent/sessions/readers/grok.rb, lib/agent/sessions/readers/qwen.rb, lib/agent/sessions/adapters/base.rb, lib/agent/sessions/adapters/grok.rb, lib/agent/sessions/adapters/qwen.rb, lib/agent/sessions/readers/codex.rb, lib/agent/sessions/unknown_agent.rb, lib/agent/sessions/adapters/codex.rb, lib/agent/sessions/home_expansion.rb, lib/agent/sessions/readers/claude.rb, lib/agent/sessions/readers/gemini.rb, lib/agent/sessions/adapters/claude.rb, lib/agent/sessions/adapters/cursor.rb, lib/agent/sessions/adapters/gemini.rb, lib/agent/sessions/readers/copilot.rb, lib/agent/sessions/adapters/copilot.rb, lib/agent/sessions/readers/opencode.rb, lib/agent/sessions/unreadable_store.rb, lib/agent/sessions/adapters/opencode.rb, lib/agent/sessions/missing_dependency.rb, lib/agent/sessions/unsupported_format.rb, lib/agent/sessions/adapters/cursor_ide.rb, lib/agent/sessions/adapters/enumeration.rb |
6
6
 
7
7
  ## Constants
8
8
  ### `LOADER` <a id="constant-LOADER"></a> <a id="LOADER-constant"></a>
@@ -168,6 +168,7 @@ Not documented.
168
168
  - [doc/Agent/Sessions/Readers/Opencode.md](doc/Agent/Sessions/Readers/Opencode.md)
169
169
  - [doc/Agent/Sessions/Readers/Pi.md](doc/Agent/Sessions/Readers/Pi.md)
170
170
  - [doc/Agent/Sessions/Readers/Qwen.md](doc/Agent/Sessions/Readers/Qwen.md)
171
+ - [doc/Agent/Sessions/RoundTrip.md](doc/Agent/Sessions/RoundTrip.md)
171
172
  - [doc/Agent/Sessions/Session.md](doc/Agent/Sessions/Session.md)
172
173
  - [doc/Agent/Sessions/Sqlite.md](doc/Agent/Sessions/Sqlite.md)
173
174
  - [doc/Agent/Sessions/Store.md](doc/Agent/Sessions/Store.md)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent_sessions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucian Ghinda
@@ -91,6 +91,7 @@ files:
91
91
  - doc/Agent/Sessions/Readers/Opencode.md
92
92
  - doc/Agent/Sessions/Readers/Pi.md
93
93
  - doc/Agent/Sessions/Readers/Qwen.md
94
+ - doc/Agent/Sessions/RoundTrip.md
94
95
  - doc/Agent/Sessions/Session.md
95
96
  - doc/Agent/Sessions/Sqlite.md
96
97
  - doc/Agent/Sessions/Store.md
@@ -138,6 +139,7 @@ files:
138
139
  - lib/agent/sessions/readers/opencode.rb
139
140
  - lib/agent/sessions/readers/pi.rb
140
141
  - lib/agent/sessions/readers/qwen.rb
142
+ - lib/agent/sessions/round_trip.rb
141
143
  - lib/agent/sessions/session.rb
142
144
  - lib/agent/sessions/sqlite.rb
143
145
  - lib/agent/sessions/store.rb