agent-session_context 0.1.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 +28 -0
- data/LICENSE.txt +21 -0
- data/README.md +268 -0
- data/exe/agent-session-context +6 -0
- data/lib/agent/session_context/builder.rb +146 -0
- data/lib/agent/session_context/cli/options.rb +213 -0
- data/lib/agent/session_context/cli.rb +229 -0
- data/lib/agent/session_context/config.rb +206 -0
- data/lib/agent/session_context/errors.rb +15 -0
- data/lib/agent/session_context/evidence_collector.rb +227 -0
- data/lib/agent/session_context/evidence_packet.rb +271 -0
- data/lib/agent/session_context/immutable_value.rb +71 -0
- data/lib/agent/session_context/injected_context.rb +92 -0
- data/lib/agent/session_context/injected_context_collector.rb +53 -0
- data/lib/agent/session_context/item.rb +54 -0
- data/lib/agent/session_context/prompt.rb +40 -0
- data/lib/agent/session_context/prompt_extractor.rb +31 -0
- data/lib/agent/session_context/renderers/human_display.rb +113 -0
- data/lib/agent/session_context/renderers/json.rb +13 -0
- data/lib/agent/session_context/renderers/json_lines.rb +13 -0
- data/lib/agent/session_context/renderers/markdown.rb +126 -0
- data/lib/agent/session_context/renderers/serializer.rb +124 -0
- data/lib/agent/session_context/renderers/text.rb +122 -0
- data/lib/agent/session_context/semantic_categories.rb +89 -0
- data/lib/agent/session_context/semantic_pipeline.rb +151 -0
- data/lib/agent/session_context/semantic_schema.rb +75 -0
- data/lib/agent/session_context/session_resolver.rb +147 -0
- data/lib/agent/session_context/snapshot.rb +128 -0
- data/lib/agent/session_context/source_ref.rb +46 -0
- data/lib/agent/session_context/subprocess_runner.rb +362 -0
- data/lib/agent/session_context/summarizers/claude.rb +126 -0
- data/lib/agent/session_context/summarizers/codex.rb +132 -0
- data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
- data/lib/agent/session_context/summarizers.rb +35 -0
- data/lib/agent/session_context/summary_parser.rb +219 -0
- data/lib/agent/session_context/transcript.rb +236 -0
- data/lib/agent/session_context/version.rb +7 -0
- data/lib/agent/session_context.rb +56 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 354885a52b8cbdd91468f869bb75a09850c1cb9b065f5c05d87f2ec83d70e9cd
|
|
4
|
+
data.tar.gz: c0bdeac8d7994aa514e01b7b08b57273d843e67359ce29875447925bfe665a23
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d3be9d23e7d1ae974cdc4f516a61159e586e88573a2fea32e8d28137220d298036cc4a461f87c98c29787c13a5ee51789e45e7e55df4858db22a57bde529455b
|
|
7
|
+
data.tar.gz: 2cbf65397f9bd18dda3fdfda24fc49f6633946d66f82b0589d221a5a11afb63415c1cbe222f5be58e1a6009012e700ad5189570271c030ee9b24a08e4ac745ba
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-09-01
|
|
4
|
+
|
|
5
|
+
- Use the `agent-session_context` distribution, `Agent::SessionContext`
|
|
6
|
+
namespace, and `agent-session-context` executable to avoid collisions with
|
|
7
|
+
the existing `agent-context` gem and `Agent::Context` namespace.
|
|
8
|
+
- Let `--current` choose the uniquely latest session metadata timestamp only
|
|
9
|
+
when no supported session environment identifier is present. Present
|
|
10
|
+
identifiers keep their validation and errors; `--agent` narrows disk
|
|
11
|
+
fallback, empty stores raise `CurrentSessionUnavailable`, the CLI warns with
|
|
12
|
+
the selected UID, and exact ties refuse with explicit-session guidance.
|
|
13
|
+
- Make `show` include exact user prompts and a deduplicated injected-context
|
|
14
|
+
inventory, with explicit `--include-injected`/`include_injected: true` access
|
|
15
|
+
to full injected text and documented raw-transcript exclusions.
|
|
16
|
+
- Add real Claude Code and Codex CLI JSONL fixtures for the public `show`,
|
|
17
|
+
`prompts`, and `summarize` contract.
|
|
18
|
+
- Verify prompt filtering against injected environment, AGENTS, slash-command,
|
|
19
|
+
and tool-result records while keeping observed file/tool evidence grounded to
|
|
20
|
+
source refs.
|
|
21
|
+
- Bound summarizer subprocess stdout/stderr streaming to independent `1 MiB`
|
|
22
|
+
caps, add per-call timeout controls across config/API/CLI, and package the
|
|
23
|
+
shared subprocess/config support.
|
|
24
|
+
- Verify the Codex summarization backend against a live recorded session.
|
|
25
|
+
- Document the recorded-evidence model, CLI/API surface, privacy boundaries,
|
|
26
|
+
and provider boundaries.
|
|
27
|
+
- Include source, changelog, and issue-tracker links for the public GitHub
|
|
28
|
+
repository in the gem metadata.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucian Ghinda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Agent Session Context
|
|
2
|
+
|
|
3
|
+
[](https://github.com/lucianghinda/agent-session-context/actions/workflows/main.yml)
|
|
4
|
+
[](https://www.ruby-lang.org/)
|
|
5
|
+
[](LICENSE.txt)
|
|
6
|
+
|
|
7
|
+
Inspect and summarize recorded Claude Code and Codex sessions.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Use Ruby 3.2 or newer.
|
|
12
|
+
|
|
13
|
+
Add this line to your application's **Gemfile**:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "agent-session_context"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
Show the latest recorded session:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
agent-session-context show --current
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
### Basic Usage
|
|
36
|
+
|
|
37
|
+
Show an exact session:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
agent-session-context show codex:SESSION_ID
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Disambiguate a bare identifier:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
agent-session-context show SESSION_ID --agent codex
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
List exact user prompts:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
agent-session-context prompts --current
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Render prompts as JSON Lines:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
agent-session-context prompts --current --format jsonl
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Current Sessions
|
|
62
|
+
|
|
63
|
+
Use these variables for `--current`, in order:
|
|
64
|
+
|
|
65
|
+
1. `AGENT_SESSION_ID` with `AGENT_NAME`
|
|
66
|
+
2. `CLAUDE_CODE_SESSION_ID`
|
|
67
|
+
3. `CODEX_SESSION_ID`
|
|
68
|
+
4. `CODEX_THREAD_ID`
|
|
69
|
+
|
|
70
|
+
Without variables, let the command select the unique newest session metadata.
|
|
71
|
+
|
|
72
|
+
Pass `--agent` to restrict that disk search.
|
|
73
|
+
|
|
74
|
+
Keep present identifiers authoritative.
|
|
75
|
+
|
|
76
|
+
Never trigger fallback after validating a present identifier.
|
|
77
|
+
|
|
78
|
+
Clear conflicting Claude and Codex identifiers before retrying.
|
|
79
|
+
|
|
80
|
+
Expect missing targets, empty stores, and timestamp ties to fail.
|
|
81
|
+
|
|
82
|
+
Read the selected UID from the CLI warning.
|
|
83
|
+
|
|
84
|
+
Treat disk selection as recency, not live context.
|
|
85
|
+
|
|
86
|
+
### Local Context
|
|
87
|
+
|
|
88
|
+
Include deduplicated injected text explicitly:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
agent-session-context show --current --include-injected
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Review exact prompts and injected text before sharing them.
|
|
95
|
+
|
|
96
|
+
Expect `show` to exclude assistant messages, thinking, tool-result bodies, and raw envelopes.
|
|
97
|
+
|
|
98
|
+
Use `show` and `prompts` without starting a model.
|
|
99
|
+
|
|
100
|
+
### Summaries
|
|
101
|
+
|
|
102
|
+
Create a grounded summary:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
agent-session-context summarize --current
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Choose a backend and timeout:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
agent-session-context summarize --current --using codex --timeout 45
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Codex summarization was tested successfully against a live recorded session.
|
|
115
|
+
|
|
116
|
+
Expect summaries to cite recorded source references.
|
|
117
|
+
|
|
118
|
+
Expect summaries to exclude thinking, tool results, injected blocks, and raw records.
|
|
119
|
+
|
|
120
|
+
Treat Codex filesystem access as read-only, not hermetic.
|
|
121
|
+
|
|
122
|
+
### Ruby API
|
|
123
|
+
|
|
124
|
+
Resolve and inspect a session:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
require "agent/session_context"
|
|
128
|
+
|
|
129
|
+
session = Agent::SessionContext.resolve("codex:SESSION_ID")
|
|
130
|
+
snapshot = Agent::SessionContext.show(session)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Inspect the newest recorded Codex session:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
session = Agent::SessionContext.current(agent: :codex, env: {})
|
|
137
|
+
prompts = Agent::SessionContext.prompts(session)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Create a summary with built-in settings:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
summary = Agent::SessionContext.summarize(session, using: :codex, timeout: 45)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Use a custom summarizer:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
summary = Agent::SessionContext.summarize(
|
|
150
|
+
session,
|
|
151
|
+
summarizer: ->(prompt:, schema:) { call_your_model(prompt, schema) }
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Replace `call_your_model` with your adapter.
|
|
156
|
+
|
|
157
|
+
Return a JSON string matching the provided schema.
|
|
158
|
+
|
|
159
|
+
Pass either `summarizer:` or `timeout:`, never both.
|
|
160
|
+
|
|
161
|
+
### Supported Public Ruby API
|
|
162
|
+
|
|
163
|
+
`Agent::SessionContext.resolve` and `Agent::SessionContext.current` return `Agent::Sessions::Session`.
|
|
164
|
+
|
|
165
|
+
`Agent::SessionContext.show` returns an `Agent::SessionContext::Snapshot` whose collections contain `Agent::SessionContext::Prompt`, `Agent::SessionContext::InjectedContext`, `Agent::SessionContext::Item`, and `Agent::SessionContext::SourceRef` values as applicable.
|
|
166
|
+
|
|
167
|
+
`Agent::SessionContext.prompts` returns an array of `Agent::SessionContext::Prompt` values.
|
|
168
|
+
|
|
169
|
+
`Agent::SessionContext.summarize` returns an `Agent::SessionContext::Snapshot` populated with summary `Agent::SessionContext::Item` values and summary metadata.
|
|
170
|
+
|
|
171
|
+
`Agent::SessionContext::Snapshot`, `Agent::SessionContext::Prompt`, `Agent::SessionContext::InjectedContext`, `Agent::SessionContext::Item`, and `Agent::SessionContext::SourceRef` are part of the supported public data model.
|
|
172
|
+
|
|
173
|
+
`Agent::SessionContext::VERSION` is public.
|
|
174
|
+
|
|
175
|
+
`Agent::SessionContext::CLI::FORMATS` is the supported frozen list of CLI output format names.
|
|
176
|
+
|
|
177
|
+
The public error classes listed in [Errors](#errors) are part of the compatibility contract.
|
|
178
|
+
|
|
179
|
+
### Internal Architecture
|
|
180
|
+
|
|
181
|
+
`resolve -> capture -> extract/collect -> optionally summarize -> build snapshot -> render`.
|
|
182
|
+
|
|
183
|
+
`show` can expose injected text only when you opt into `include_injected`, while `summarize` keeps injected blocks and tool-result bodies out of the model prompt.
|
|
184
|
+
|
|
185
|
+
Except for `Agent::SessionContext::CLI::FORMATS`, the CLI implementation, builders, collectors, parsers, runners, renderers, and built-in summarizer adapters are internal details without compatibility guarantees.
|
|
186
|
+
|
|
187
|
+
## Options
|
|
188
|
+
|
|
189
|
+
| Option | Description |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `--current` | Use environment identity, then the newest disk metadata. |
|
|
192
|
+
| `--agent claude\|codex` | Restrict explicit lookup or disk fallback. |
|
|
193
|
+
| `--format FORMAT` | Choose text, Markdown, JSON, or JSON Lines when supported. |
|
|
194
|
+
| `--include-injected` | Include deduplicated injected text with `show`. |
|
|
195
|
+
| `--using BACKEND` | Choose `auto`, `claude`, or `codex` for summaries. |
|
|
196
|
+
| `--timeout SECONDS` | Set each provider call timeout from 1 through 3600 seconds. |
|
|
197
|
+
|
|
198
|
+
Run `agent-session-context help` for command details.
|
|
199
|
+
|
|
200
|
+
### Configuration
|
|
201
|
+
|
|
202
|
+
Expect only `summarize` to load configuration.
|
|
203
|
+
|
|
204
|
+
Configuration paths retain the original `agent-context` name for compatibility.
|
|
205
|
+
|
|
206
|
+
Create `.agent-context.yml` in the recorded project:
|
|
207
|
+
|
|
208
|
+
```yaml
|
|
209
|
+
summarize:
|
|
210
|
+
timeout_seconds: 300
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Set user defaults in `$XDG_CONFIG_HOME/agent_context/config.yml`.
|
|
214
|
+
|
|
215
|
+
Otherwise, use `$HOME/.config/agent_context/config.yml`.
|
|
216
|
+
|
|
217
|
+
Use `XDG_CONFIG_HOME` exclusively when it contains an absolute path.
|
|
218
|
+
|
|
219
|
+
Let project configuration override user defaults.
|
|
220
|
+
|
|
221
|
+
Pass `--timeout` to override both files.
|
|
222
|
+
|
|
223
|
+
Use finite numbers from `1` through `3600`.
|
|
224
|
+
|
|
225
|
+
Apply the timeout to each provider call.
|
|
226
|
+
|
|
227
|
+
### Output Formats
|
|
228
|
+
|
|
229
|
+
| Command | Formats |
|
|
230
|
+
|---|---|
|
|
231
|
+
| `show` | `text`, `markdown`, `json` |
|
|
232
|
+
| `prompts` | `text`, `markdown`, `json`, `jsonl` |
|
|
233
|
+
| `summarize` | `text`, `markdown`, `json` |
|
|
234
|
+
|
|
235
|
+
### Errors
|
|
236
|
+
|
|
237
|
+
Handle these public errors:
|
|
238
|
+
|
|
239
|
+
- `Agent::SessionContext::SessionNotFound`
|
|
240
|
+
- `Agent::SessionContext::AmbiguousSession`
|
|
241
|
+
- `Agent::SessionContext::CurrentSessionUnavailable`
|
|
242
|
+
- `Agent::SessionContext::UnsupportedAgent`
|
|
243
|
+
- `Agent::SessionContext::ConfigurationError`
|
|
244
|
+
- `Agent::SessionContext::SummarizerUnavailable`
|
|
245
|
+
- `Agent::SessionContext::SummarizerFailed`
|
|
246
|
+
- `Agent::SessionContext::InvalidSummary`
|
|
247
|
+
|
|
248
|
+
## Contributing
|
|
249
|
+
|
|
250
|
+
Fork the repository and create a branch.
|
|
251
|
+
|
|
252
|
+
Run the test suite:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
bundle exec rake test
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Open a pull request with tests and documentation.
|
|
259
|
+
|
|
260
|
+
Follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
|
261
|
+
|
|
262
|
+
Report bugs through [GitHub Issues](https://github.com/lucianghinda/agent-session-context/issues).
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
Use the gem under the MIT License.
|
|
267
|
+
|
|
268
|
+
See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
class Builder
|
|
6
|
+
PromptsResult = Data.define(:prompts, :reader_warnings) do
|
|
7
|
+
def partial_capture?
|
|
8
|
+
!reader_warnings.empty?
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
catalog: Agent::Sessions,
|
|
14
|
+
now: Time.now,
|
|
15
|
+
collector: EvidenceCollector.new,
|
|
16
|
+
prompt_extractor: PromptExtractor.new,
|
|
17
|
+
injected_context_collector: InjectedContextCollector.new
|
|
18
|
+
)
|
|
19
|
+
@catalog = catalog
|
|
20
|
+
@now = now
|
|
21
|
+
@collector = collector
|
|
22
|
+
@prompt_extractor = prompt_extractor
|
|
23
|
+
@injected_context_collector = injected_context_collector
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show(session, include_injected: false)
|
|
27
|
+
validate_include_injected!(include_injected)
|
|
28
|
+
transcript = capture(session)
|
|
29
|
+
observed = @collector.call(transcript)
|
|
30
|
+
|
|
31
|
+
build_snapshot(
|
|
32
|
+
session:,
|
|
33
|
+
transcript:,
|
|
34
|
+
observed:,
|
|
35
|
+
prompts: @prompt_extractor.call(transcript),
|
|
36
|
+
injected_context: @injected_context_collector.call(transcript, include_text: include_injected),
|
|
37
|
+
warnings: transcript.warnings,
|
|
38
|
+
summary_metadata: base_metadata(transcript)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def prompts(session)
|
|
43
|
+
prompts_result(session).prompts
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def prompts_result(session)
|
|
47
|
+
transcript = capture(session)
|
|
48
|
+
PromptsResult.new(
|
|
49
|
+
prompts: @prompt_extractor.call(transcript),
|
|
50
|
+
reader_warnings: transcript.warnings
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def summarize(session, summarizer:)
|
|
55
|
+
transcript = capture(session)
|
|
56
|
+
observed = @collector.call(transcript)
|
|
57
|
+
semantic = SemanticPipeline.new(backend: summarizer).call(transcript:, observed:)
|
|
58
|
+
semantic_collections, unknown_warnings = collect_semantic_items(semantic.items)
|
|
59
|
+
|
|
60
|
+
build_snapshot(
|
|
61
|
+
session: session,
|
|
62
|
+
transcript: transcript,
|
|
63
|
+
observed: observed,
|
|
64
|
+
semantic_collections: semantic_collections,
|
|
65
|
+
warnings: transcript.warnings + semantic.warnings + unknown_warnings,
|
|
66
|
+
summary_metadata: semantic.metadata.merge(base_metadata(transcript))
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def validate_include_injected!(value)
|
|
73
|
+
return if value.equal?(true) || value.equal?(false)
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, "include_injected must be true or false"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def capture(session)
|
|
79
|
+
Transcript.capture(session, reader: @catalog.read(session), now: @now)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def collect_semantic_items(items)
|
|
83
|
+
collections = empty_semantic_collections
|
|
84
|
+
warnings = []
|
|
85
|
+
|
|
86
|
+
items.each do |item|
|
|
87
|
+
field = SemanticCategories.lookup(item.kind)&.snapshot_field
|
|
88
|
+
unless field
|
|
89
|
+
warnings << "Dropped unknown semantic kind #{item.kind.inspect}"
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
collections[field] << item
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
[collections.transform_values(&:freeze).freeze, warnings.freeze]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def empty_semantic_collections
|
|
100
|
+
SemanticCategories.snapshot_fields.to_h do |field|
|
|
101
|
+
[field, []]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_snapshot(
|
|
106
|
+
session:,
|
|
107
|
+
transcript:,
|
|
108
|
+
observed:,
|
|
109
|
+
warnings:,
|
|
110
|
+
summary_metadata:,
|
|
111
|
+
prompts: [],
|
|
112
|
+
injected_context: [],
|
|
113
|
+
semantic_collections: empty_semantic_collections.transform_values(&:freeze).freeze
|
|
114
|
+
)
|
|
115
|
+
Snapshot.new(
|
|
116
|
+
session_uid: session.uid,
|
|
117
|
+
agent: session.agent,
|
|
118
|
+
project_path: session.project_path,
|
|
119
|
+
captured_at: transcript.captured_at,
|
|
120
|
+
message_count: transcript.entries.length,
|
|
121
|
+
prompts:,
|
|
122
|
+
injected_context:,
|
|
123
|
+
files: observed.files,
|
|
124
|
+
documents: observed.documents,
|
|
125
|
+
tool_activity: observed.tool_activity,
|
|
126
|
+
**semantic_snapshot_attributes(semantic_collections),
|
|
127
|
+
warnings: warnings,
|
|
128
|
+
summary_metadata: summary_metadata
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def semantic_snapshot_attributes(semantic_collections)
|
|
133
|
+
SemanticCategories.snapshot_fields.to_h do |field|
|
|
134
|
+
[field, semantic_collections.fetch(field)]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def base_metadata(transcript)
|
|
139
|
+
metadata = { injected_parts_filtered: transcript.entries.sum { |entry| entry.parts.count(&:injected) } }
|
|
140
|
+
reader_warning_count = transcript.warnings.length
|
|
141
|
+
metadata[:reader_warning_count] = reader_warning_count if reader_warning_count.positive?
|
|
142
|
+
metadata
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module SessionContext
|
|
7
|
+
class CLI
|
|
8
|
+
class Options
|
|
9
|
+
AGENTS = %w[claude codex].freeze
|
|
10
|
+
BACKENDS = %w[auto claude codex].freeze
|
|
11
|
+
OPTION_DEFINITIONS = {
|
|
12
|
+
current: {
|
|
13
|
+
long: "--current",
|
|
14
|
+
value: false,
|
|
15
|
+
register: lambda do |parser, options|
|
|
16
|
+
parser.on("--current") { options[:current] = true }
|
|
17
|
+
end
|
|
18
|
+
}.freeze,
|
|
19
|
+
agent: {
|
|
20
|
+
long: "--agent",
|
|
21
|
+
value: true,
|
|
22
|
+
register: lambda do |parser, options|
|
|
23
|
+
parser.on("--agent AGENT", AGENTS) { |value| options[:agent] = value.to_sym }
|
|
24
|
+
end
|
|
25
|
+
}.freeze,
|
|
26
|
+
format: {
|
|
27
|
+
long: "--format",
|
|
28
|
+
value: true,
|
|
29
|
+
register: lambda do |parser, options|
|
|
30
|
+
parser.on("--format FORMAT", CLI::FORMATS) { |value| options[:format] = value.to_sym }
|
|
31
|
+
end
|
|
32
|
+
}.freeze,
|
|
33
|
+
using: {
|
|
34
|
+
long: "--using",
|
|
35
|
+
value: true,
|
|
36
|
+
register: lambda do |parser, options|
|
|
37
|
+
parser.on("--using BACKEND", BACKENDS) { |value| options[:using] = value.to_sym }
|
|
38
|
+
end
|
|
39
|
+
}.freeze,
|
|
40
|
+
timeout: {
|
|
41
|
+
long: "--timeout",
|
|
42
|
+
value: true,
|
|
43
|
+
register: lambda do |parser, options|
|
|
44
|
+
parser.on("--timeout SECONDS") { |value| options[:timeout] = parse_timeout_argument(value) }
|
|
45
|
+
end
|
|
46
|
+
}.freeze,
|
|
47
|
+
include_injected: {
|
|
48
|
+
long: "--include-injected",
|
|
49
|
+
value: false,
|
|
50
|
+
register: lambda do |parser, options|
|
|
51
|
+
parser.on("--include-injected") { options[:include_injected] = true }
|
|
52
|
+
end
|
|
53
|
+
}.freeze
|
|
54
|
+
}.freeze
|
|
55
|
+
COMMANDS = {
|
|
56
|
+
show: { formats: %i[text markdown json], options: %i[current agent format include_injected] }.freeze,
|
|
57
|
+
prompts: { formats: %i[text markdown json jsonl], options: %i[current agent format] }.freeze,
|
|
58
|
+
summarize: { formats: %i[text markdown json], options: %i[current agent format using timeout] }.freeze,
|
|
59
|
+
help: { formats: nil, options: [].freeze }.freeze,
|
|
60
|
+
version: { formats: nil, options: [].freeze }.freeze
|
|
61
|
+
}.transform_values do |descriptor|
|
|
62
|
+
option_names = descriptor.fetch(:options)
|
|
63
|
+
long_options = option_names.each_with_object({}) do |name, rules|
|
|
64
|
+
definition = OPTION_DEFINITIONS.fetch(name)
|
|
65
|
+
rules[definition.fetch(:long)] = definition.fetch(:value) ? :value : :flag
|
|
66
|
+
end.freeze
|
|
67
|
+
|
|
68
|
+
descriptor.merge(
|
|
69
|
+
formats: descriptor[:formats]&.freeze,
|
|
70
|
+
options: option_names.freeze,
|
|
71
|
+
long_options:
|
|
72
|
+
).freeze
|
|
73
|
+
end.freeze
|
|
74
|
+
private_constant :AGENTS, :BACKENDS, :OPTION_DEFINITIONS, :COMMANDS
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
def parse(arguments, command:)
|
|
78
|
+
descriptor = command_descriptor(command)
|
|
79
|
+
options = default_options
|
|
80
|
+
validate_argument_encoding!(arguments)
|
|
81
|
+
validate_exact_long_options!(arguments, descriptor:)
|
|
82
|
+
remaining = parser(options, descriptor:).permute(arguments.dup)
|
|
83
|
+
|
|
84
|
+
validate_selection!(options, remaining, command:, descriptor:)
|
|
85
|
+
options.merge(identifier: remaining.first).freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def hinted_format(arguments)
|
|
89
|
+
limit = arguments.index("--") || arguments.length
|
|
90
|
+
effective = :text
|
|
91
|
+
index = 0
|
|
92
|
+
|
|
93
|
+
while index < limit
|
|
94
|
+
token = arguments[index]
|
|
95
|
+
|
|
96
|
+
if token == "--format"
|
|
97
|
+
candidate = arguments[index + 1]
|
|
98
|
+
effective = candidate.to_sym if CLI::FORMATS.include?(candidate)
|
|
99
|
+
index += 2
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if token.start_with?("--format=")
|
|
104
|
+
candidate = token.split("=", 2).last
|
|
105
|
+
effective = candidate.to_sym if CLI::FORMATS.include?(candidate)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
index += 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
effective
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_no_args(arguments, command:)
|
|
115
|
+
descriptor = command_descriptor(command)
|
|
116
|
+
validate_argument_encoding!(arguments)
|
|
117
|
+
validate_exact_long_options!(arguments, descriptor:)
|
|
118
|
+
remaining = OptionParser.new.permute(arguments.dup)
|
|
119
|
+
raise OptionParser::ParseError, "unexpected arguments: #{remaining.join(" ")}" unless remaining.empty?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def default_options
|
|
125
|
+
{
|
|
126
|
+
current: false,
|
|
127
|
+
agent: nil,
|
|
128
|
+
format: :text,
|
|
129
|
+
using: :auto,
|
|
130
|
+
timeout: nil,
|
|
131
|
+
include_injected: false
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def parser(options, descriptor:)
|
|
136
|
+
OptionParser.new do |parser|
|
|
137
|
+
descriptor.fetch(:options).each do |name|
|
|
138
|
+
OPTION_DEFINITIONS.fetch(name).fetch(:register).call(parser, options)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_selection!(options, remaining, command:, descriptor:)
|
|
144
|
+
allowed_formats = descriptor.fetch(:formats)
|
|
145
|
+
unless allowed_formats.include?(options[:format])
|
|
146
|
+
raise OptionParser::ParseError, "--format #{options[:format]} is not supported for #{command}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if options[:current] && !remaining.empty?
|
|
150
|
+
raise OptionParser::ParseError, "SESSION and --current are mutually exclusive"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if remaining.length > 1
|
|
154
|
+
raise OptionParser::ParseError, "unexpected arguments: #{remaining.drop(1).join(" ")}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
raise OptionParser::ParseError, "pass SESSION or --current" if !options[:current] && remaining.empty?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_argument_encoding!(arguments)
|
|
161
|
+
arguments.each do |argument|
|
|
162
|
+
next unless argument.is_a?(String)
|
|
163
|
+
|
|
164
|
+
candidate = argument.dup
|
|
165
|
+
candidate.force_encoding(Encoding::UTF_8)
|
|
166
|
+
raise OptionParser::InvalidArgument, "arguments must be valid UTF-8" unless candidate.valid_encoding?
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_exact_long_options!(arguments, descriptor:)
|
|
171
|
+
allowed = descriptor.fetch(:long_options)
|
|
172
|
+
limit = arguments.index("--") || arguments.length
|
|
173
|
+
index = 0
|
|
174
|
+
|
|
175
|
+
while index < limit
|
|
176
|
+
token = arguments[index]
|
|
177
|
+
if token.start_with?("--")
|
|
178
|
+
name, value = token.split("=", 2)
|
|
179
|
+
rule = allowed[name]
|
|
180
|
+
raise OptionParser::InvalidOption, token unless rule
|
|
181
|
+
raise OptionParser::InvalidOption, token if rule == :flag && !value.nil?
|
|
182
|
+
|
|
183
|
+
if rule == :value && value.nil?
|
|
184
|
+
index += 2
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
index += 1
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def command_descriptor(command)
|
|
194
|
+
COMMANDS.fetch(command.to_sym)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_timeout_argument(value)
|
|
198
|
+
Integer(value, 10)
|
|
199
|
+
rescue ArgumentError, TypeError
|
|
200
|
+
return Float::NAN if value.casecmp("nan").zero?
|
|
201
|
+
return Float::INFINITY if value.casecmp("infinity").zero? || value.casecmp("inf").zero?
|
|
202
|
+
return -Float::INFINITY if value.casecmp("-infinity").zero? || value.casecmp("-inf").zero?
|
|
203
|
+
|
|
204
|
+
parsed = Float(value, exception: false)
|
|
205
|
+
return parsed unless parsed.nil?
|
|
206
|
+
|
|
207
|
+
raise OptionParser::InvalidArgument, value
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|