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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +268 -0
  5. data/exe/agent-session-context +6 -0
  6. data/lib/agent/session_context/builder.rb +146 -0
  7. data/lib/agent/session_context/cli/options.rb +213 -0
  8. data/lib/agent/session_context/cli.rb +229 -0
  9. data/lib/agent/session_context/config.rb +206 -0
  10. data/lib/agent/session_context/errors.rb +15 -0
  11. data/lib/agent/session_context/evidence_collector.rb +227 -0
  12. data/lib/agent/session_context/evidence_packet.rb +271 -0
  13. data/lib/agent/session_context/immutable_value.rb +71 -0
  14. data/lib/agent/session_context/injected_context.rb +92 -0
  15. data/lib/agent/session_context/injected_context_collector.rb +53 -0
  16. data/lib/agent/session_context/item.rb +54 -0
  17. data/lib/agent/session_context/prompt.rb +40 -0
  18. data/lib/agent/session_context/prompt_extractor.rb +31 -0
  19. data/lib/agent/session_context/renderers/human_display.rb +113 -0
  20. data/lib/agent/session_context/renderers/json.rb +13 -0
  21. data/lib/agent/session_context/renderers/json_lines.rb +13 -0
  22. data/lib/agent/session_context/renderers/markdown.rb +126 -0
  23. data/lib/agent/session_context/renderers/serializer.rb +124 -0
  24. data/lib/agent/session_context/renderers/text.rb +122 -0
  25. data/lib/agent/session_context/semantic_categories.rb +89 -0
  26. data/lib/agent/session_context/semantic_pipeline.rb +151 -0
  27. data/lib/agent/session_context/semantic_schema.rb +75 -0
  28. data/lib/agent/session_context/session_resolver.rb +147 -0
  29. data/lib/agent/session_context/snapshot.rb +128 -0
  30. data/lib/agent/session_context/source_ref.rb +46 -0
  31. data/lib/agent/session_context/subprocess_runner.rb +362 -0
  32. data/lib/agent/session_context/summarizers/claude.rb +126 -0
  33. data/lib/agent/session_context/summarizers/codex.rb +132 -0
  34. data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
  35. data/lib/agent/session_context/summarizers.rb +35 -0
  36. data/lib/agent/session_context/summary_parser.rb +219 -0
  37. data/lib/agent/session_context/transcript.rb +236 -0
  38. data/lib/agent/session_context/version.rb +7 -0
  39. data/lib/agent/session_context.rb +56 -0
  40. metadata +112 -0
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Summarizers
6
+ module CommandExecutionPolicy
7
+ COMMON_ENV_KEYS = %w[
8
+ ALL_PROXY
9
+ HOME
10
+ HTTP_PROXY
11
+ HTTPS_PROXY
12
+ LANG
13
+ NO_PROXY
14
+ PATH
15
+ SSL_CERT_DIR
16
+ SSL_CERT_FILE
17
+ all_proxy
18
+ http_proxy
19
+ https_proxy
20
+ no_proxy
21
+ ].freeze
22
+ private_constant :COMMON_ENV_KEYS
23
+
24
+ private
25
+
26
+ def initialize_command_execution_policy(runner:, timeout_seconds:, max_output_bytes:, provider_env_keys:)
27
+ @timeout_seconds = timeout_seconds
28
+ @max_output_bytes = max_output_bytes
29
+ @provider_env_keys = provider_env_keys.dup.freeze
30
+ @runner = runner || SubprocessRunner.new(timeout_seconds:, max_output_bytes:)
31
+ end
32
+
33
+ def run_command!(prompt:, argv:)
34
+ response = @runner.call(env: child_env, argv:, stdin_data: prompt)
35
+ validate_runner_response!(response)
36
+ rescue Errno::ENOENT => e
37
+ raise SummarizerUnavailable, "#{name} summarizer is unavailable: #{e.message}"
38
+ rescue SubprocessRunner::TimeoutError
39
+ raise SummarizerFailed, "#{name} summarizer exceeded its #{@timeout_seconds}-second timeout"
40
+ rescue SubprocessRunner::OutputLimitError => e
41
+ raise SummarizerFailed,
42
+ "#{name} summarizer #{translated_stream_label(e.stream)} exceeded #{@max_output_bytes} bytes"
43
+ end
44
+
45
+ def validate_runner_response!(response)
46
+ unless response.is_a?(Array) && response.length == 3
47
+ raise SummarizerFailed, "#{name} summarizer runner must return [stdout, stderr, status]"
48
+ end
49
+
50
+ stdout, stderr, status = response
51
+
52
+ unless status.respond_to?(:success?)
53
+ raise SummarizerFailed, "#{name} summarizer runner must return a status with #success?"
54
+ end
55
+
56
+ unless stdout.nil? || stdout.is_a?(String)
57
+ raise SummarizerFailed, "#{name} summarizer runner must return stdout as a String"
58
+ end
59
+
60
+ unless stderr.nil? || stderr.is_a?(String)
61
+ raise SummarizerFailed, "#{name} summarizer runner must return stderr as a String"
62
+ end
63
+
64
+ stdout = stdout.to_s
65
+ stderr = stderr.to_s
66
+ enforce_output_bounds!("stdout", stdout, status:)
67
+ enforce_output_bounds!("stderr", stderr, status:)
68
+
69
+ [stdout, stderr, status]
70
+ end
71
+
72
+ def failure_message(summary, stdout: nil, stderr: nil, output: nil, status: nil)
73
+ fragments = [summary]
74
+ append_stream_metadata(fragments, "stdout", stdout)
75
+ append_stream_metadata(fragments, "stderr", stderr)
76
+ append_stream_metadata(fragments, "output", output)
77
+ append_status_metadata(fragments, status)
78
+ fragments.join(". ")
79
+ end
80
+
81
+ def child_env
82
+ ENV.each_with_object({}) do |(key, value), filtered|
83
+ filtered[key] = value if allowed_env_key?(key)
84
+ end
85
+ end
86
+
87
+ def allowed_env_key?(key)
88
+ COMMON_ENV_KEYS.include?(key) || @provider_env_keys.include?(key) || key.start_with?("LC_")
89
+ end
90
+
91
+ def enforce_output_bounds!(label, value, status:)
92
+ return if value.bytesize <= @max_output_bytes
93
+
94
+ raise SummarizerFailed, failure_message(
95
+ "#{name} summarizer #{label} exceeded #{@max_output_bytes} bytes",
96
+ status:,
97
+ stdout: label == "stdout" ? value : nil,
98
+ stderr: label == "stderr" ? value : nil
99
+ )
100
+ end
101
+
102
+ def append_stream_metadata(fragments, label, value)
103
+ state =
104
+ if value.nil?
105
+ "absent"
106
+ elsif value.empty?
107
+ "empty"
108
+ else
109
+ "present"
110
+ end
111
+
112
+ bytesize = value.nil? ? 0 : value.bytesize
113
+ fragments << "#{label}=#{state}(#{bytesize} bytes)"
114
+ end
115
+
116
+ def append_status_metadata(fragments, status)
117
+ return unless status.respond_to?(:exitstatus)
118
+
119
+ exitstatus = status.exitstatus
120
+ return if exitstatus.nil?
121
+
122
+ fragments << "exit_status=#{exitstatus}"
123
+ end
124
+
125
+ def translated_stream_label(stream)
126
+ return "stdout" if stream.equal?(:stdout)
127
+ return "stderr" if stream.equal?(:stderr)
128
+
129
+ "output"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Summarizers
6
+ module_function
7
+
8
+ def for(name, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
9
+ case normalize_name(name)
10
+ when :codex then Codex.new(timeout_seconds:)
11
+ when :claude then Claude.new(timeout_seconds:)
12
+ else
13
+ raise unsupported_summarizer(name)
14
+ end
15
+ end
16
+
17
+ def normalize_name(name)
18
+ return name if name.is_a?(Symbol)
19
+ raise unsupported_summarizer(name) unless name.respond_to?(:to_sym)
20
+
21
+ normalized = name.to_sym
22
+ raise unsupported_summarizer(name) unless normalized.is_a?(Symbol)
23
+
24
+ normalized
25
+ rescue NoMethodError, TypeError, ArgumentError
26
+ raise unsupported_summarizer(name)
27
+ end
28
+
29
+ def unsupported_summarizer(name)
30
+ UnsupportedAgent.new("unsupported summarizer #{name.inspect}; use claude or codex")
31
+ end
32
+ private_class_method :normalize_name, :unsupported_summarizer
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class SummaryParser
6
+ Result = Data.define(:items, :warnings)
7
+
8
+ ALLOWED_EVIDENCE = %w[explicit inferred].freeze
9
+ TEXT_KEYS = %w[text evidence source_refs].freeze
10
+ TERM_KEYS = %w[term definition evidence source_refs].freeze
11
+ ITEM_KEYS = {
12
+ text: TEXT_KEYS,
13
+ term: TERM_KEYS
14
+ }.freeze
15
+ REQUIRED_CATEGORIES = SemanticCategories.external_keys
16
+
17
+ def call(json, allowed_refs:)
18
+ payload = parse_payload(json)
19
+ allowed_ref_index = build_allowed_ref_index(allowed_refs)
20
+ validate_known_categories!(payload)
21
+ warnings = []
22
+ merged_items = {}
23
+ ordered_items = []
24
+
25
+ payload.each do |category, value|
26
+ definition = SemanticCategories.lookup(category)
27
+ unless definition
28
+ warnings << "Dropped unknown category #{category.inspect}"
29
+ next
30
+ end
31
+
32
+ value.each_with_index do |raw_item, item_index|
33
+ item = parse_item(definition, raw_item, item_index, allowed_ref_index, warnings)
34
+ next unless item
35
+
36
+ key = [item.kind, item.label, item.detail]
37
+ existing = merged_items[key]
38
+
39
+ if existing
40
+ merged_items[key] = merge_item_refs(existing, item)
41
+ else
42
+ merged_items[key] = item
43
+ ordered_items << key
44
+ end
45
+ end
46
+ end
47
+
48
+ Result.new(
49
+ items: ordered_items.map { |key| merged_items.fetch(key) }.freeze,
50
+ warnings: warnings.map { |warning| String.new(warning).freeze }.freeze
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def parse_payload(json)
57
+ payload = JSON.parse(json)
58
+ rescue JSON::ParserError
59
+ raise InvalidSummary, "Invalid JSON summary. Provide a JSON top-level object with the supported categories."
60
+ else
61
+ raise InvalidSummary, "Summary must be a JSON top-level object." unless payload.is_a?(Hash)
62
+
63
+ payload
64
+ end
65
+
66
+ def validate_known_categories!(payload)
67
+ REQUIRED_CATEGORIES.each do |category|
68
+ next unless payload.key?(category)
69
+
70
+ raise InvalidSummary, "#{category} must be an array" unless payload[category].is_a?(Array)
71
+ end
72
+
73
+ missing_categories = REQUIRED_CATEGORIES - payload.keys
74
+ return if missing_categories.empty?
75
+
76
+ raise InvalidSummary,
77
+ "Summary is missing required categories: #{missing_categories.join(", ")}"
78
+ end
79
+
80
+ def build_allowed_ref_index(allowed_refs)
81
+ normalize_allowed_refs(allowed_refs).to_h do |source_ref|
82
+ [source_ref.to_s, source_ref]
83
+ end
84
+ end
85
+
86
+ def normalize_allowed_refs(allowed_refs)
87
+ refs =
88
+ if allowed_refs.is_a?(Array)
89
+ allowed_refs
90
+ elsif allowed_refs.respond_to?(:to_a) && !allowed_refs.is_a?(String)
91
+ allowed_refs.to_a
92
+ elsif allowed_refs.respond_to?(:each) && !allowed_refs.is_a?(String)
93
+ allowed_refs.each_with_object([]) { |source_ref, collected| collected << source_ref }
94
+ else
95
+ raise TypeError, "allowed_refs must be an enumerable of Agent::SessionContext::SourceRef objects"
96
+ end
97
+
98
+ refs.each do |source_ref|
99
+ unless source_ref.is_a?(SourceRef)
100
+ raise TypeError,
101
+ "allowed_refs must contain only Agent::SessionContext::SourceRef objects"
102
+ end
103
+ end
104
+
105
+ refs
106
+ end
107
+
108
+ def parse_item(category, raw_item, item_index, allowed_ref_index, warnings)
109
+ category_name = category.external_key
110
+
111
+ unless raw_item.is_a?(Hash)
112
+ warnings << "Dropped #{category_name}[#{item_index}] because items must be objects"
113
+ return
114
+ end
115
+
116
+ expected_keys = ITEM_KEYS.fetch(category.item_shape)
117
+ raw_keys = raw_item.keys.sort
118
+
119
+ unless raw_keys == expected_keys.sort
120
+ warnings << key_mismatch_warning(category_name, item_index, raw_keys, expected_keys)
121
+ return
122
+ end
123
+
124
+ evidence = raw_item["evidence"]
125
+ unless ALLOWED_EVIDENCE.include?(evidence)
126
+ warnings << "Dropped #{category_name}[#{item_index}] because evidence must be explicit or inferred"
127
+ return
128
+ end
129
+
130
+ refs = resolve_source_refs(category_name, item_index, raw_item["source_refs"], allowed_ref_index, warnings)
131
+ return unless refs
132
+
133
+ case category.item_shape
134
+ when :term
135
+ term = raw_item["term"]
136
+ definition = raw_item["definition"]
137
+
138
+ unless term.is_a?(String)
139
+ warnings << "Dropped #{category_name}[#{item_index}] because term must be a string"
140
+ return
141
+ end
142
+
143
+ unless definition.is_a?(String)
144
+ warnings << "Dropped #{category_name}[#{item_index}] because definition must be a string"
145
+ return
146
+ end
147
+
148
+ Item.new(
149
+ kind: category.internal_kind,
150
+ label: term,
151
+ detail: definition,
152
+ evidence: evidence.to_sym,
153
+ source_refs: refs
154
+ )
155
+ else
156
+ text = raw_item["text"]
157
+ unless text.is_a?(String)
158
+ warnings << "Dropped #{category_name}[#{item_index}] because text must be a string"
159
+ return
160
+ end
161
+
162
+ Item.new(
163
+ kind: category.internal_kind,
164
+ label: text,
165
+ detail: nil,
166
+ evidence: evidence.to_sym,
167
+ source_refs: refs
168
+ )
169
+ end
170
+ end
171
+
172
+ def resolve_source_refs(category, item_index, raw_refs, allowed_ref_index, warnings)
173
+ unless raw_refs.is_a?(Array) && !raw_refs.empty? && raw_refs.all?(String)
174
+ warnings << "Dropped #{category}[#{item_index}] because source_refs must be a non-empty array of strings"
175
+ return
176
+ end
177
+
178
+ resolved = raw_refs.map { |value| allowed_ref_index[value] }
179
+ if resolved.any?(&:nil?)
180
+ warnings << "Dropped #{category}[#{item_index}] because it referenced an unknown source ref"
181
+ return
182
+ end
183
+
184
+ resolved.uniq.freeze
185
+ end
186
+
187
+ def merge_item_refs(existing, item)
188
+ Item.new(
189
+ kind: existing.kind,
190
+ label: existing.label,
191
+ detail: existing.detail,
192
+ evidence: merged_evidence(existing.evidence, item.evidence),
193
+ source_refs: (existing.source_refs + item.source_refs).uniq,
194
+ attributes: existing.attributes
195
+ )
196
+ end
197
+
198
+ def merged_evidence(left, right)
199
+ return :explicit if left == :explicit || right == :explicit
200
+
201
+ :inferred
202
+ end
203
+
204
+ def key_mismatch_warning(category, item_index, raw_keys, expected_keys)
205
+ missing_keys = expected_keys - raw_keys
206
+ extra_keys = raw_keys - expected_keys
207
+ details = []
208
+ details << "missing #{missing_keys.join(", ")}" unless missing_keys.empty?
209
+ details << "extra keys #{extra_keys.join(", ")}" unless extra_keys.empty?
210
+ details << "additional properties are not allowed" unless extra_keys.empty?
211
+ detail_text = details.join("; ")
212
+ detail_suffix = " (#{detail_text})" unless detail_text.empty?
213
+
214
+ "Dropped #{category}[#{item_index}] because item keys must exactly match " \
215
+ "#{expected_keys.join(", ")}#{detail_suffix}"
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ TranscriptPart = Data.define(:index, :type, :text, :name, :call_id, :injected, :source_ref) do
6
+ def initialize(index:, type:, injected:, source_ref:, text: nil, name: nil, call_id: nil)
7
+ super(
8
+ index: normalize_positive_index(index, :index),
9
+ type: normalize_symbol(type, :type),
10
+ text: normalize_optional_string(text, :text),
11
+ name: normalize_optional_string(name, :name),
12
+ call_id: normalize_optional_string(call_id, :call_id),
13
+ injected: normalize_boolean(injected),
14
+ source_ref: normalize_source_ref(source_ref)
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ def normalize_positive_index(value, name)
21
+ index =
22
+ if value.is_a?(Integer)
23
+ value
24
+ elsif value.respond_to?(:to_int)
25
+ value.to_int
26
+ elsif value.is_a?(String)
27
+ Integer(value, exception: false)
28
+ end
29
+
30
+ raise TypeError, "#{name} must be an Integer or integer-like value" if index.nil?
31
+ raise ArgumentError, "#{name} must be greater than or equal to 1" if index < 1
32
+
33
+ index
34
+ end
35
+
36
+ def normalize_symbol(value, name)
37
+ return value if value.is_a?(Symbol)
38
+ return value.to_sym if value.respond_to?(:to_sym)
39
+
40
+ raise TypeError, "#{name} must be symbolizable"
41
+ end
42
+
43
+ def normalize_optional_string(value, name)
44
+ return if value.nil?
45
+
46
+ raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
47
+
48
+ String.new(value.to_str).freeze
49
+ end
50
+
51
+ def normalize_boolean(value)
52
+ return value if [true, false].include?(value)
53
+
54
+ raise TypeError, "injected must be true or false"
55
+ end
56
+
57
+ def normalize_source_ref(value)
58
+ raise TypeError, "source_ref must be an Agent::SessionContext::SourceRef" unless value.is_a?(SourceRef)
59
+
60
+ value
61
+ end
62
+ end
63
+
64
+ TranscriptEntry = Data.define(:index, :role, :at, :parts) do
65
+ def initialize(index:, role:, at:, parts:)
66
+ super(
67
+ index: normalize_positive_index(index, :index),
68
+ role: normalize_symbol(role, :role),
69
+ at: at,
70
+ parts: normalize_parts(parts)
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def normalize_positive_index(value, name)
77
+ index =
78
+ if value.is_a?(Integer)
79
+ value
80
+ elsif value.respond_to?(:to_int)
81
+ value.to_int
82
+ elsif value.is_a?(String)
83
+ Integer(value, exception: false)
84
+ end
85
+
86
+ raise TypeError, "#{name} must be an Integer or integer-like value" if index.nil?
87
+ raise ArgumentError, "#{name} must be greater than or equal to 1" if index < 1
88
+
89
+ index
90
+ end
91
+
92
+ def normalize_symbol(value, name)
93
+ return value if value.is_a?(Symbol)
94
+ return value.to_sym if value.respond_to?(:to_sym)
95
+
96
+ raise TypeError, "#{name} must be symbolizable"
97
+ end
98
+
99
+ def normalize_parts(value)
100
+ Array(value).map do |part|
101
+ unless part.is_a?(TranscriptPart)
102
+ raise TypeError,
103
+ "parts must contain only Agent::SessionContext::TranscriptPart values"
104
+ end
105
+
106
+ part
107
+ end.freeze
108
+ end
109
+ end
110
+
111
+ Transcript = Data.define(:session, :captured_at, :entries, :warnings) do
112
+ class << self
113
+ def capture(session, reader: Agent::Sessions.read(session), now: Time.now)
114
+ entries = []
115
+
116
+ reader.each_message.with_index(1) do |message, message_index|
117
+ entries << TranscriptEntry.new(
118
+ index: message_index,
119
+ role: message.role,
120
+ at: message.at,
121
+ parts: capture_parts(session, message, message_index)
122
+ )
123
+ end
124
+
125
+ new(
126
+ session: session,
127
+ captured_at: now,
128
+ entries: entries.freeze,
129
+ warnings: capture_warnings(reader)
130
+ )
131
+ end
132
+
133
+ def injection_kind(agent, text)
134
+ return unless text.is_a?(String)
135
+
136
+ stripped_text = text.b.lstrip
137
+ marker_kinds = Transcript::INJECTION_MARKERS.fetch(agent&.to_sym, {})
138
+ match = marker_kinds.find { |marker, _kind| stripped_text.start_with?(marker.b) }
139
+ match&.last
140
+ end
141
+
142
+ private
143
+
144
+ def capture_parts(session, message, message_index)
145
+ message.parts.each_with_index.map do |part, part_index|
146
+ TranscriptPart.new(
147
+ index: part_index + 1,
148
+ type: part.type,
149
+ text: part.text,
150
+ name: part.name,
151
+ call_id: part.call_id,
152
+ injected: injected_part?(session.agent, message, part),
153
+ source_ref: SourceRef.new(
154
+ session_uid: session.uid,
155
+ message_index: message_index,
156
+ part_index: part_index + 1
157
+ )
158
+ )
159
+ end.freeze
160
+ end
161
+
162
+ def capture_warnings(reader)
163
+ Array(reader.warnings).map do |warning|
164
+ raise TypeError, "warning must be a String" unless warning.respond_to?(:to_str)
165
+
166
+ String.new(warning.to_str).freeze
167
+ end.freeze
168
+ end
169
+
170
+ def injected_part?(agent, message, part)
171
+ return false unless message.role == :user
172
+ return false unless part.type == :text
173
+
174
+ raw_meta_message?(message.raw) || marker_injected?(agent, part.text)
175
+ end
176
+
177
+ def raw_meta_message?(raw)
178
+ raw.is_a?(Hash) && raw["isMeta"] == true
179
+ end
180
+
181
+ def marker_injected?(agent, text)
182
+ !injection_kind(agent, text).nil?
183
+ end
184
+ end
185
+
186
+ def initialize(session:, captured_at:, entries:, warnings:)
187
+ super(
188
+ session: session,
189
+ captured_at: captured_at,
190
+ entries: normalize_entries(entries),
191
+ warnings: normalize_warnings(warnings)
192
+ )
193
+ end
194
+
195
+ private
196
+
197
+ def normalize_entries(value)
198
+ Array(value).map do |entry|
199
+ unless entry.is_a?(TranscriptEntry)
200
+ raise TypeError,
201
+ "entries must contain only Agent::SessionContext::TranscriptEntry values"
202
+ end
203
+
204
+ entry
205
+ end.freeze
206
+ end
207
+
208
+ def normalize_warnings(value)
209
+ Array(value).map do |warning|
210
+ raise TypeError, "warning must be a String" unless warning.respond_to?(:to_str)
211
+
212
+ String.new(warning.to_str).freeze
213
+ end.freeze
214
+ end
215
+ end
216
+
217
+ Transcript.const_set(
218
+ :INJECTION_MARKERS,
219
+ {
220
+ claude: {
221
+ "<command-name>" => :command_name,
222
+ "<command-message>" => :command_message,
223
+ "<command-args>" => :command_args,
224
+ "<local-command-stdout>" => :local_command_stdout,
225
+ "<local-command-stderr>" => :local_command_stderr,
226
+ "<system-reminder>" => :system_reminder
227
+ }.freeze,
228
+ codex: {
229
+ "<environment_context>" => :environment_context,
230
+ "<user_instructions>" => :user_instructions,
231
+ "# AGENTS.md instructions" => :agents_instructions
232
+ }.freeze
233
+ }.freeze
234
+ )
235
+ end
236
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "agent_sessions"
6
+ require "zeitwerk"
7
+ require_relative "session_context/errors"
8
+
9
+ module Agent
10
+ module SessionContext
11
+ LOADER = Zeitwerk::Loader.for_gem_extension(Agent)
12
+ LOADER.ignore(File.expand_path("session_context/errors.rb", __dir__))
13
+ LOADER.inflector.inflect("cli" => "CLI", "json" => "JSON", "json_lines" => "JSONLines")
14
+ LOADER.setup
15
+ private_constant :LOADER
16
+
17
+ class << self
18
+ def resolve(identifier, agent: nil, env: ENV, catalog: Agent::Sessions)
19
+ SessionResolver.new(catalog:, env: env).resolve(identifier, agent: agent)
20
+ end
21
+
22
+ def current(agent: nil, env: ENV, catalog: Agent::Sessions)
23
+ SessionResolver.new(catalog:, env: env).current(agent:)
24
+ end
25
+
26
+ def show(session, include_injected: false, **)
27
+ Builder.new(**).show(session, include_injected:)
28
+ end
29
+
30
+ def prompts(session, **)
31
+ Builder.new(**).prompts(session)
32
+ end
33
+
34
+ def summarize(
35
+ session,
36
+ using: nil,
37
+ summarizer: nil,
38
+ timeout: nil,
39
+ env: ENV,
40
+ config_loader: Config,
41
+ backend_factory: Summarizers,
42
+ **
43
+ )
44
+ if summarizer
45
+ raise ArgumentError, "timeout applies only to built-in summarizers" unless timeout.nil?
46
+
47
+ return Builder.new(**).summarize(session, summarizer:)
48
+ end
49
+
50
+ config = config_loader.load(session:, env:, timeout:)
51
+ backend = backend_factory.for(using || session.agent, timeout_seconds: config.timeout_seconds)
52
+ Builder.new(**).summarize(session, summarizer: backend)
53
+ end
54
+ end
55
+ end
56
+ end