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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module SessionContext
|
|
7
|
+
class CLI
|
|
8
|
+
FORMATS = %w[text markdown json jsonl].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
argv,
|
|
12
|
+
env: ENV,
|
|
13
|
+
stdout: $stdout,
|
|
14
|
+
stderr: $stderr,
|
|
15
|
+
now: Time.now,
|
|
16
|
+
resolver: SessionResolver.new(env: env),
|
|
17
|
+
builder: Builder.new(now: now),
|
|
18
|
+
backend_factory: Summarizers,
|
|
19
|
+
config_loader: Config
|
|
20
|
+
)
|
|
21
|
+
@argv = argv.dup
|
|
22
|
+
@env = env
|
|
23
|
+
@stdout = stdout
|
|
24
|
+
@stderr = stderr
|
|
25
|
+
@resolver = resolver
|
|
26
|
+
@builder = builder
|
|
27
|
+
@backend_factory = backend_factory
|
|
28
|
+
@config_loader = config_loader
|
|
29
|
+
@current_format = :text
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run
|
|
33
|
+
case (command = @argv.shift)
|
|
34
|
+
when "show" then show
|
|
35
|
+
when "prompts" then prompts
|
|
36
|
+
when "summarize" then summarize
|
|
37
|
+
when "version", "--version", "-v" then version(@argv)
|
|
38
|
+
when nil then help([], @stdout, 0)
|
|
39
|
+
when "help", "--help", "-h" then help(@argv, @stdout, 0)
|
|
40
|
+
else
|
|
41
|
+
@stderr.puts "unknown command: #{safe_text(command)}"
|
|
42
|
+
1
|
|
43
|
+
end
|
|
44
|
+
rescue Agent::SessionContext::Error, OptionParser::ParseError => e
|
|
45
|
+
emit_error(e)
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def show
|
|
52
|
+
@current_format = Options.hinted_format(@argv)
|
|
53
|
+
selection = Options.parse(@argv, command: :show)
|
|
54
|
+
session = resolve_session(selection)
|
|
55
|
+
include_injected = selection.fetch(:include_injected)
|
|
56
|
+
snapshot = @builder.show(session, include_injected:)
|
|
57
|
+
if include_injected
|
|
58
|
+
@stderr.puts "warning: exact prompts and full injected context may contain secrets; review before sharing"
|
|
59
|
+
else
|
|
60
|
+
@stderr.puts "warning: exact prompts may contain secrets; review before sharing"
|
|
61
|
+
end
|
|
62
|
+
emit_warnings(session.uid, snapshot.warnings)
|
|
63
|
+
write_output(snapshot, format: selection.fetch(:format))
|
|
64
|
+
partial_capture_status(snapshot)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def prompts
|
|
68
|
+
@current_format = Options.hinted_format(@argv)
|
|
69
|
+
selection = Options.parse(@argv, command: :prompts)
|
|
70
|
+
session = resolve_session(selection)
|
|
71
|
+
prompts_result = @builder.prompts_result(session)
|
|
72
|
+
@stderr.puts "warning: exact prompts may contain secrets; review before sharing"
|
|
73
|
+
emit_warnings(session.uid, prompts_result.reader_warnings)
|
|
74
|
+
write_output(prompts_result.prompts, format: selection.fetch(:format))
|
|
75
|
+
prompts_result.partial_capture? ? 1 : 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def summarize
|
|
79
|
+
@current_format = Options.hinted_format(@argv)
|
|
80
|
+
selection = Options.parse(@argv, command: :summarize)
|
|
81
|
+
session = resolve_session(selection)
|
|
82
|
+
config = @config_loader.load(session:, env: @env, timeout: selection.fetch(:timeout))
|
|
83
|
+
backend_name = selection.fetch(:using) == :auto ? session.agent : selection.fetch(:using)
|
|
84
|
+
timeout_seconds = configured_timeout_seconds(config)
|
|
85
|
+
@stderr.puts "summarizing with #{safe_text(backend_name)} (timeout: #{format_timeout(timeout_seconds)}s)"
|
|
86
|
+
summarizer = @backend_factory.for(backend_name, timeout_seconds:)
|
|
87
|
+
snapshot = @builder.summarize(session, summarizer:)
|
|
88
|
+
emit_warnings(session.uid, snapshot.warnings)
|
|
89
|
+
write_output(snapshot, format: selection.fetch(:format))
|
|
90
|
+
partial_capture_status(snapshot)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def version(arguments)
|
|
94
|
+
Options.parse_no_args(arguments, command: :version)
|
|
95
|
+
@stdout.puts Agent::SessionContext::VERSION
|
|
96
|
+
0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def help(arguments, io, status)
|
|
100
|
+
Options.parse_no_args(arguments, command: :help)
|
|
101
|
+
io.puts <<~HELP
|
|
102
|
+
Usage: agent-session-context COMMAND [options] [SESSION]
|
|
103
|
+
|
|
104
|
+
Commands:
|
|
105
|
+
show
|
|
106
|
+
prompts
|
|
107
|
+
summarize
|
|
108
|
+
version
|
|
109
|
+
help
|
|
110
|
+
|
|
111
|
+
Common options:
|
|
112
|
+
--current Use environment identity, else latest on disk
|
|
113
|
+
--agent claude|codex Narrow explicit lookup or --current disk fallback
|
|
114
|
+
--format text|markdown|json|jsonl
|
|
115
|
+
|
|
116
|
+
Show behavior:
|
|
117
|
+
Includes exact user prompts and an injected-context inventory.
|
|
118
|
+
--include-injected Include deduplicated full injected text
|
|
119
|
+
Excludes assistant messages, thinking, tool-result bodies,
|
|
120
|
+
and raw provider envelopes.
|
|
121
|
+
|
|
122
|
+
Summarize options:
|
|
123
|
+
--using auto|claude|codex
|
|
124
|
+
--timeout SECONDS Set a 1-3600s timeout for each provider call
|
|
125
|
+
HELP
|
|
126
|
+
status
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resolve_session(selection)
|
|
130
|
+
if selection.fetch(:current)
|
|
131
|
+
@resolver.current(agent: selection.fetch(:agent)) do |session|
|
|
132
|
+
@stderr.puts(
|
|
133
|
+
"warning: --current found no session environment identifier; " \
|
|
134
|
+
"using latest session on disk: #{safe_text(session.uid)}"
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
@resolver.resolve(selection.fetch(:identifier), agent: selection.fetch(:agent))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def emit_warnings(session_uid, warnings)
|
|
143
|
+
Array(warnings).each do |warning|
|
|
144
|
+
@stderr.puts "warning: #{safe_text(session_uid)}: #{safe_text(warning)}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def write_output(value, format:)
|
|
149
|
+
rendered = renderer_for(format).call(value)
|
|
150
|
+
if human_format?(format)
|
|
151
|
+
@stdout.write(rendered)
|
|
152
|
+
@stdout.write("\n")
|
|
153
|
+
else
|
|
154
|
+
@stdout.write(rendered)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def renderer_for(format)
|
|
159
|
+
case format
|
|
160
|
+
when :text then Renderers::Text.new
|
|
161
|
+
when :markdown then Renderers::Markdown.new
|
|
162
|
+
when :json then Renderers::JSON.new
|
|
163
|
+
when :jsonl then Renderers::JSONLines.new
|
|
164
|
+
else
|
|
165
|
+
raise OptionParser::ParseError, "unsupported format #{format.inspect}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def human_format?(format)
|
|
170
|
+
%i[text markdown].include?(format)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def emit_error(error)
|
|
174
|
+
if @current_format == :json
|
|
175
|
+
error_json = JSON.generate(
|
|
176
|
+
{
|
|
177
|
+
error: {
|
|
178
|
+
type: scrubbed_error_type(error),
|
|
179
|
+
message: Renderers::Serializer.scrub_string(normalized_error_message(error))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
@stdout.write(error_json)
|
|
184
|
+
else
|
|
185
|
+
@stderr.puts safe_text(normalized_error_message(error))
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def safe_text(value)
|
|
190
|
+
Renderers::HumanDisplay.text_inline(value)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def partial_capture_status(snapshot)
|
|
194
|
+
snapshot.summary_metadata.fetch(:reader_warning_count, 0).positive? ? 1 : 0
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def configured_timeout_seconds(config)
|
|
198
|
+
value = config.timeout_seconds
|
|
199
|
+
if value.is_a?(Integer) && value >= Config::MIN_TIMEOUT_SECONDS && value <= Config::MAX_TIMEOUT_SECONDS
|
|
200
|
+
return value
|
|
201
|
+
end
|
|
202
|
+
if value.is_a?(Float) &&
|
|
203
|
+
value.finite? &&
|
|
204
|
+
value >= Config::MIN_TIMEOUT_SECONDS &&
|
|
205
|
+
value <= Config::MAX_TIMEOUT_SECONDS
|
|
206
|
+
return value
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
raise ConfigurationError, "Invalid configured timeout_seconds."
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def format_timeout(value)
|
|
213
|
+
return value.to_i.to_s if value.is_a?(Float) && value.finite? && value == value.to_i
|
|
214
|
+
|
|
215
|
+
value.to_s
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def normalized_error_message(error)
|
|
219
|
+
return error.args.first.to_s if error.instance_of?(OptionParser::ParseError)
|
|
220
|
+
|
|
221
|
+
error.message.to_s
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def scrubbed_error_type(error)
|
|
225
|
+
Renderers::Serializer.scrub_string(error.class.name.to_s)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "psych"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module SessionContext
|
|
8
|
+
class Config < Data.define(:timeout_seconds)
|
|
9
|
+
DEFAULT_TIMEOUT_SECONDS = 300
|
|
10
|
+
MIN_TIMEOUT_SECONDS = 1
|
|
11
|
+
MAX_TIMEOUT_SECONDS = 3600
|
|
12
|
+
ABSENT = Object.new.freeze
|
|
13
|
+
PROJECT_CONFIG_FILENAME = ".agent-context.yml"
|
|
14
|
+
USER_CONFIG_PATH_SEGMENTS = [".config", "agent_context", "config.yml"].freeze
|
|
15
|
+
private_constant :ABSENT, :PROJECT_CONFIG_FILENAME, :USER_CONFIG_PATH_SEGMENTS
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def load(session:, env: ENV, timeout: nil)
|
|
19
|
+
project_timeout = load_layer(project_source(session))
|
|
20
|
+
user_timeout = load_layer(user_source(env))
|
|
21
|
+
|
|
22
|
+
effective_timeout =
|
|
23
|
+
if timeout.nil?
|
|
24
|
+
if project_timeout.nil?
|
|
25
|
+
user_timeout.nil? ? DEFAULT_TIMEOUT_SECONDS : user_timeout
|
|
26
|
+
else
|
|
27
|
+
project_timeout
|
|
28
|
+
end
|
|
29
|
+
else
|
|
30
|
+
validate_timeout!(timeout, source: "timeout")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
new(timeout_seconds: effective_timeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def load_layer(source)
|
|
39
|
+
return nil unless source
|
|
40
|
+
|
|
41
|
+
path = source.fetch(:path)
|
|
42
|
+
stat = stat_config_path(path, source)
|
|
43
|
+
return nil unless stat
|
|
44
|
+
|
|
45
|
+
raise_configuration_error(source, "must be a regular file") unless stat.file?
|
|
46
|
+
|
|
47
|
+
content = read_utf8(path, stat, source)
|
|
48
|
+
raw = parse_yaml(content, source)
|
|
49
|
+
timeout = extract_timeout(raw, source)
|
|
50
|
+
|
|
51
|
+
return nil if timeout.equal?(ABSENT)
|
|
52
|
+
|
|
53
|
+
validate_timeout!(timeout, source: source.fetch(:label), path:)
|
|
54
|
+
rescue ConfigurationError
|
|
55
|
+
raise
|
|
56
|
+
rescue Psych::Exception
|
|
57
|
+
raise_configuration_error(source, "contains invalid YAML")
|
|
58
|
+
rescue SystemCallError
|
|
59
|
+
raise_configuration_error(source, "could not be read")
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
raise_configuration_error(source, "contains invalid encoding")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stat_config_path(path, source)
|
|
65
|
+
File.lstat(path)
|
|
66
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
67
|
+
nil
|
|
68
|
+
rescue SystemCallError
|
|
69
|
+
raise_configuration_error(source, "could not be read")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def project_source(session)
|
|
73
|
+
project_path = normalize_absolute_path(session.project_path)
|
|
74
|
+
return nil unless project_path
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
label: "project configuration",
|
|
78
|
+
path: File.join(project_path, PROJECT_CONFIG_FILENAME)
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def user_source(env)
|
|
83
|
+
xdg_config_home = normalize_absolute_path(env["XDG_CONFIG_HOME"])
|
|
84
|
+
if xdg_config_home
|
|
85
|
+
return {
|
|
86
|
+
label: "user configuration",
|
|
87
|
+
path: File.join(xdg_config_home, "agent_context", "config.yml")
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
home = normalize_absolute_path(env["HOME"])
|
|
92
|
+
return nil unless home
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
label: "user configuration",
|
|
96
|
+
path: File.join(home, *USER_CONFIG_PATH_SEGMENTS)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def normalize_absolute_path(value)
|
|
101
|
+
return nil unless value.respond_to?(:to_str)
|
|
102
|
+
|
|
103
|
+
path = value.to_str
|
|
104
|
+
return nil if path.empty?
|
|
105
|
+
return path if Pathname.new(path).absolute?
|
|
106
|
+
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_utf8(path, initial_stat, source)
|
|
111
|
+
open_config_file(path, source) do |file|
|
|
112
|
+
descriptor_stat = file.stat
|
|
113
|
+
raise_configuration_error(source, "must be a regular file") unless descriptor_stat.file?
|
|
114
|
+
ensure_same_identity!(initial_stat, descriptor_stat, source)
|
|
115
|
+
|
|
116
|
+
content = file.read
|
|
117
|
+
content = content.dup.force_encoding(Encoding::UTF_8)
|
|
118
|
+
raise_configuration_error(source, "contains invalid encoding") unless content.valid_encoding?
|
|
119
|
+
|
|
120
|
+
content
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_yaml(content, source)
|
|
125
|
+
return nil if content.empty?
|
|
126
|
+
|
|
127
|
+
Psych.safe_load(
|
|
128
|
+
content,
|
|
129
|
+
permitted_classes: [],
|
|
130
|
+
permitted_symbols: [],
|
|
131
|
+
aliases: false
|
|
132
|
+
)
|
|
133
|
+
rescue Psych::Exception
|
|
134
|
+
raise_configuration_error(source, "contains invalid YAML")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def extract_timeout(raw, source)
|
|
138
|
+
return ABSENT if raw.nil?
|
|
139
|
+
return ABSENT if raw == {}
|
|
140
|
+
|
|
141
|
+
raise_configuration_error(source, "must be a mapping") unless raw.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
unknown_keys = raw.keys - ["summarize"]
|
|
144
|
+
raise_configuration_error(source, "contains unsupported settings") unless unknown_keys.empty?
|
|
145
|
+
|
|
146
|
+
summarize = raw.fetch("summarize")
|
|
147
|
+
raise_configuration_error(source, "summarize must be a mapping") unless summarize.is_a?(Hash)
|
|
148
|
+
return ABSENT if summarize.empty?
|
|
149
|
+
|
|
150
|
+
unknown_summarize_keys = summarize.keys - ["timeout_seconds"]
|
|
151
|
+
unless unknown_summarize_keys.empty?
|
|
152
|
+
raise_configuration_error(source,
|
|
153
|
+
"contains unsupported summarize settings")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
return ABSENT unless summarize.key?("timeout_seconds")
|
|
157
|
+
|
|
158
|
+
summarize.fetch("timeout_seconds")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_timeout!(value, source:, path: nil)
|
|
162
|
+
unless value.is_a?(Integer) || value.is_a?(Float)
|
|
163
|
+
raise_configuration_error({ label: source, path: },
|
|
164
|
+
"timeout_seconds must be an Integer or Float between " \
|
|
165
|
+
"#{MIN_TIMEOUT_SECONDS} and #{MAX_TIMEOUT_SECONDS}")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
unless value.finite? &&
|
|
169
|
+
value >= MIN_TIMEOUT_SECONDS && value <= MAX_TIMEOUT_SECONDS
|
|
170
|
+
raise_configuration_error({ label: source, path: },
|
|
171
|
+
"timeout_seconds must be a finite number between " \
|
|
172
|
+
"#{MIN_TIMEOUT_SECONDS} and #{MAX_TIMEOUT_SECONDS}")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
value
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def open_config_file(path, source)
|
|
179
|
+
flags = File::RDONLY
|
|
180
|
+
flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
|
|
181
|
+
|
|
182
|
+
File.open(path, flags) do |file|
|
|
183
|
+
file.binmode
|
|
184
|
+
yield file
|
|
185
|
+
end
|
|
186
|
+
rescue SystemCallError, IOError
|
|
187
|
+
raise_configuration_error(source, "could not be read")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def ensure_same_identity!(initial_stat, descriptor_stat, source)
|
|
191
|
+
return if initial_stat.dev == descriptor_stat.dev && initial_stat.ino == descriptor_stat.ino
|
|
192
|
+
|
|
193
|
+
raise_configuration_error(source, "changed while being read")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def raise_configuration_error(source, reason)
|
|
197
|
+
label = source.fetch(:label)
|
|
198
|
+
path = source[:path]
|
|
199
|
+
location = path ? " at #{path}" : ""
|
|
200
|
+
|
|
201
|
+
raise ConfigurationError, "Invalid #{label}#{location}: #{reason}."
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
class SessionNotFound < Error; end
|
|
8
|
+
class AmbiguousSession < Error; end
|
|
9
|
+
class CurrentSessionUnavailable < Error; end
|
|
10
|
+
class UnsupportedAgent < Error; end
|
|
11
|
+
class SummarizerUnavailable < Error; end
|
|
12
|
+
class SummarizerFailed < Error; end
|
|
13
|
+
class InvalidSummary < Error; end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
class EvidenceCollector
|
|
6
|
+
DOCUMENT_BASENAMES = %w[agents.md claude.md readme readme.md].freeze
|
|
7
|
+
DOCUMENT_EXTENSIONS = %w[.md .markdown .txt .pdf .doc .docx .odt .rtf].freeze
|
|
8
|
+
PATH_KEYS = %w[path file_path filename source destination target].freeze
|
|
9
|
+
# rubocop:disable-next Layout/LineLength -- splitting this regexp would obscure its URL grammar
|
|
10
|
+
SCHEMELESS_URL_PATTERN = %r{(?<![A-Za-z0-9_./-])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?::\d+)?(?:/[^\s"'<>?#]+)+(?:\?[^\s"'<>#]*)?(?:#[^\s"'<>]*)?}i
|
|
11
|
+
SCHEME_URL_PATTERN = %r{[A-Za-z][A-Za-z0-9+\-.]*://[^\s"'<>]+}
|
|
12
|
+
TOOL_ACTIONS = {
|
|
13
|
+
"Read" => :read,
|
|
14
|
+
"read_file" => :read,
|
|
15
|
+
"view_image" => :read,
|
|
16
|
+
"Write" => :modified,
|
|
17
|
+
"Edit" => :modified,
|
|
18
|
+
"MultiEdit" => :modified,
|
|
19
|
+
"apply_patch" => :modified,
|
|
20
|
+
"move_file" => :modified
|
|
21
|
+
}.freeze
|
|
22
|
+
PATH_SCAN_PATTERN = %r{(?:\.\.?/|/)?[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)+}
|
|
23
|
+
|
|
24
|
+
Result = Data.define(:files, :tool_activity) do
|
|
25
|
+
def documents
|
|
26
|
+
files.select { |item| EvidenceCollector.document_item?(item) }.freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def document_item?(item)
|
|
32
|
+
item.kind == :file && document_path?(item.label)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def document_path?(path)
|
|
36
|
+
basename = File.basename(path).downcase
|
|
37
|
+
extension = File.extname(path).downcase
|
|
38
|
+
|
|
39
|
+
DOCUMENT_BASENAMES.include?(basename) || DOCUMENT_EXTENSIONS.include?(extension)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(transcript)
|
|
44
|
+
tool_activity = []
|
|
45
|
+
files = {}
|
|
46
|
+
|
|
47
|
+
transcript.entries.each do |entry|
|
|
48
|
+
entry.parts.each do |part|
|
|
49
|
+
next unless part.type == :tool_use
|
|
50
|
+
|
|
51
|
+
parsed_input = parse_input(part.text)
|
|
52
|
+
tool_activity << build_tool_item(part, parsed_input)
|
|
53
|
+
|
|
54
|
+
extract_paths(part.text, parsed_input).each do |path|
|
|
55
|
+
add_item(files, kind: :file, path: path, action: action_for(part.name), source_ref: part.source_ref)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
file_items = files.values.freeze
|
|
61
|
+
|
|
62
|
+
Result.new(files: file_items, tool_activity: tool_activity.freeze)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_tool_item(part, parsed_input)
|
|
68
|
+
Item.new(
|
|
69
|
+
kind: :tool,
|
|
70
|
+
label: part.name || "(unknown)",
|
|
71
|
+
evidence: :observed,
|
|
72
|
+
source_refs: [part.source_ref],
|
|
73
|
+
attributes: tool_attributes(part.call_id, parsed_input)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def tool_attributes(call_id, parsed_input)
|
|
78
|
+
attributes = { call_id: call_id }
|
|
79
|
+
return attributes unless parsed_input.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
attributes[:input_keys] = parsed_input.keys.map { |key| String.new(key.to_s).freeze }.sort.freeze
|
|
82
|
+
attributes
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_input(raw_input)
|
|
86
|
+
return unless raw_input.is_a?(String)
|
|
87
|
+
|
|
88
|
+
JSON.parse(raw_input)
|
|
89
|
+
rescue JSON::ParserError, TypeError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def extract_paths(raw_input, parsed_input)
|
|
94
|
+
keyed_paths = extract_keyed_paths(parsed_input)
|
|
95
|
+
return keyed_paths unless keyed_paths.empty?
|
|
96
|
+
|
|
97
|
+
scan_plain_text(raw_input)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def extract_keyed_paths(parsed_input)
|
|
101
|
+
return [] unless parsed_input.is_a?(Array) || parsed_input.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
seen = {}
|
|
104
|
+
collected = []
|
|
105
|
+
collect_keyed_paths(parsed_input, seen, collected, under_path_key: false)
|
|
106
|
+
collected.freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def collect_keyed_paths(value, seen, collected, under_path_key:)
|
|
110
|
+
case value
|
|
111
|
+
when Hash
|
|
112
|
+
value.each do |key, child|
|
|
113
|
+
collect_keyed_paths(child, seen, collected, under_path_key: under_path_key || PATH_KEYS.include?(key.to_s))
|
|
114
|
+
end
|
|
115
|
+
when Array
|
|
116
|
+
value.each do |child|
|
|
117
|
+
collect_keyed_paths(child, seen, collected, under_path_key: under_path_key)
|
|
118
|
+
end
|
|
119
|
+
when String
|
|
120
|
+
append_unique_path(collected, seen, value) if valid_keyed_path?(value) && under_path_key
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def scan_plain_text(raw_input)
|
|
125
|
+
return [].freeze unless raw_input.is_a?(String)
|
|
126
|
+
|
|
127
|
+
spans = url_spans(raw_input)
|
|
128
|
+
span_index = 0
|
|
129
|
+
seen = {}
|
|
130
|
+
|
|
131
|
+
raw_input.to_enum(:scan, PATH_SCAN_PATTERN).each_with_object([]) do |_ignored, collected|
|
|
132
|
+
match = Regexp.last_match
|
|
133
|
+
span_index = advance_span_index(spans, span_index, match.begin(0))
|
|
134
|
+
next if overlap?(spans, span_index, match.begin(0), match.end(0))
|
|
135
|
+
|
|
136
|
+
append_unique_path(collected, seen, match[0])
|
|
137
|
+
end.freeze
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def valid_keyed_path?(value)
|
|
141
|
+
!value.empty? && !value.include?("\0") && !url_like?(value)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def url_like?(value)
|
|
145
|
+
value.match?(/\A#{SCHEME_URL_PATTERN}\z/o) || value.match?(/\A#{SCHEMELESS_URL_PATTERN}\z/o)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def url_spans(raw_input)
|
|
149
|
+
spans = []
|
|
150
|
+
|
|
151
|
+
[SCHEME_URL_PATTERN, SCHEMELESS_URL_PATTERN].each do |pattern|
|
|
152
|
+
raw_input.to_enum(:scan, pattern).each do
|
|
153
|
+
match = Regexp.last_match
|
|
154
|
+
spans << [match.begin(0), match.end(0)]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
merge_spans(spans)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def merge_spans(spans)
|
|
162
|
+
return [].freeze if spans.empty?
|
|
163
|
+
|
|
164
|
+
sorted_spans = spans.sort_by { |start_index, end_index| [start_index, end_index] }
|
|
165
|
+
merged = [sorted_spans.first.dup]
|
|
166
|
+
|
|
167
|
+
sorted_spans.drop(1).each do |start_index, end_index|
|
|
168
|
+
current_span = merged.last
|
|
169
|
+
|
|
170
|
+
if start_index <= current_span[1]
|
|
171
|
+
current_span[1] = [current_span[1], end_index].max
|
|
172
|
+
else
|
|
173
|
+
merged << [start_index, end_index]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
merged.freeze
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def advance_span_index(spans, span_index, match_start)
|
|
181
|
+
span_index += 1 while span_index < spans.length && spans[span_index][1] <= match_start
|
|
182
|
+
span_index
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def overlap?(spans, span_index, _match_start, match_end)
|
|
186
|
+
span_index < spans.length && spans[span_index][0] < match_end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def append_unique_path(collected, seen, path)
|
|
190
|
+
return if seen.key?(path)
|
|
191
|
+
|
|
192
|
+
seen[path] = true
|
|
193
|
+
collected << path
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def action_for(tool_name)
|
|
197
|
+
TOOL_ACTIONS.fetch(tool_name.to_s, :referenced)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def add_item(collection, kind:, path:, action:, source_ref:)
|
|
201
|
+
key = [kind, path, action]
|
|
202
|
+
existing = collection[key]
|
|
203
|
+
|
|
204
|
+
if existing
|
|
205
|
+
return if existing.source_refs.include?(source_ref)
|
|
206
|
+
|
|
207
|
+
collection[key] = Item.new(
|
|
208
|
+
kind: kind,
|
|
209
|
+
label: path,
|
|
210
|
+
evidence: :observed,
|
|
211
|
+
source_refs: existing.source_refs + [source_ref],
|
|
212
|
+
attributes: existing.attributes
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
collection[key] = Item.new(
|
|
218
|
+
kind: kind,
|
|
219
|
+
label: path,
|
|
220
|
+
evidence: :observed,
|
|
221
|
+
source_refs: [source_ref],
|
|
222
|
+
attributes: { action: action }
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|