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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ module HumanDisplay
7
+ module_function
8
+
9
+ def text_inline(value)
10
+ sanitize_string(value, preserve_newlines: false)
11
+ end
12
+
13
+ def text_block(value)
14
+ sanitize_string(value, preserve_newlines: true).split("\n", -1).map { |line| "| #{line}" }.join("\n")
15
+ end
16
+
17
+ def markdown_text(value)
18
+ escape_markdown(text_inline(value))
19
+ end
20
+
21
+ def markdown_literal(value)
22
+ literal = text_inline(value)
23
+ return "<code></code>" if literal.empty?
24
+ return "<code>#{literal}</code>" if literal.match?(/\A +\z/)
25
+
26
+ fence = "`" * [longest_backtick_run(literal) + 1, 1].max
27
+ "#{fence}#{code_span_content(literal)}#{fence}"
28
+ end
29
+
30
+ def markdown_block(value)
31
+ content = sanitize_string(value, preserve_newlines: true)
32
+ fence = "`" * [longest_backtick_run(content) + 1, 3].max
33
+
34
+ ["#{fence}text", content, fence].join("\n")
35
+ end
36
+
37
+ def timestamp(value)
38
+ serialized = Serializer.serialize_timestamp(value)
39
+ return if serialized.nil?
40
+
41
+ serialized.to_s
42
+ end
43
+
44
+ def refs(source_refs)
45
+ Array(source_refs).map { |ref| "#{ref.message_index}:#{ref.part_index}" }.join(", ")
46
+ end
47
+
48
+ def attributes(hash, inline_formatter:)
49
+ Serializer.normalized_hash_entries(hash).map do |key, value|
50
+ "#{inline_formatter.call(key)}=#{attribute_value(value, inline_formatter)}"
51
+ end.join(", ")
52
+ end
53
+
54
+ def attribute_value(value, inline_formatter)
55
+ return "{#{attributes(value, inline_formatter:)}}" if value.is_a?(Hash)
56
+ return value.map { |entry| attribute_value(entry, inline_formatter) }.join(",") if value.is_a?(Array)
57
+
58
+ inline_formatter.call(Serializer.serialize(value).to_s)
59
+ end
60
+
61
+ def sanitize_string(value, preserve_newlines:)
62
+ scrubbed = Serializer.scrub_string(value.to_s)
63
+ buffer = String.new(encoding: Encoding::UTF_8)
64
+
65
+ scrubbed.each_codepoint do |codepoint|
66
+ if preserve_newlines && codepoint == 0x0A
67
+ buffer << "\n"
68
+ next
69
+ end
70
+
71
+ visible = visible_control_escape(codepoint)
72
+ buffer << (visible || codepoint)
73
+ end
74
+
75
+ buffer
76
+ end
77
+
78
+ def escape_markdown(value)
79
+ escaped = value.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
80
+ escaped = escaped.gsub(/([\\`*\[\]#])/, "\\\\\\1")
81
+ escaped = escaped.gsub(/(^|[^[:alnum:]])_([^_]+)_([^[:alnum:]]|$)/, '\1\\_\2\\_\3')
82
+ escaped.gsub("-", "\\-")
83
+ end
84
+
85
+ def code_span_content(literal)
86
+ return " #{literal} " if literal.start_with?("`") || literal.end_with?("`")
87
+ return " #{literal} " if literal.start_with?(" ") && literal.end_with?(" ")
88
+
89
+ literal
90
+ end
91
+
92
+ def longest_backtick_run(value)
93
+ value.scan(/`+/).map(&:length).max || 0
94
+ end
95
+
96
+ def visible_control_escape(codepoint)
97
+ case codepoint
98
+ when 0x09
99
+ "\\t"
100
+ when 0x0A
101
+ "\\n"
102
+ when 0x0D
103
+ "\\r"
104
+ when 0x1B
105
+ "\\e"
106
+ when 0x00..0x08, 0x0B..0x0C, 0x0E..0x1A, 0x1C..0x1F, 0x7F, 0x80..0x9F
107
+ format("\\u%04X", codepoint)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ class JSON
7
+ def call(value)
8
+ ::JSON.generate(Serializer.serialize(value))
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ class JSONLines
7
+ def call(prompts)
8
+ Array(prompts).map { |prompt| ::JSON.generate(Serializer.serialize(prompt)) }.join("\n")
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ class Markdown
7
+ SECTION_ORDER = [
8
+ ["Goal", :goals],
9
+ ["Files", :files],
10
+ ["Documents", :documents],
11
+ ["Tool activity", :tool_activity],
12
+ ["Decisions", :decisions],
13
+ ["Terminology", :terms],
14
+ ["Constraints", :constraints],
15
+ ["Open questions", :open_questions],
16
+ ["Next actions", :next_actions],
17
+ ["Warnings", :warnings]
18
+ ].freeze
19
+ private_constant :SECTION_ORDER
20
+
21
+ def call(value)
22
+ if value.is_a?(Snapshot)
23
+ render_snapshot(value)
24
+ else
25
+ render_prompts(Array(value))
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def render_snapshot(snapshot)
32
+ sections = [render_session(snapshot)]
33
+ sections << render_snapshot_prompts(snapshot.prompts) if snapshot.prompts.any?
34
+ sections << render_injected_context(snapshot.injected_context) if snapshot.injected_context.any?
35
+
36
+ SECTION_ORDER.each do |title, field|
37
+ section = render_section(title, snapshot.public_send(field))
38
+ sections << section if section
39
+ end
40
+
41
+ sections.join("\n\n")
42
+ end
43
+
44
+ def render_prompts(prompts, heading_level: 2)
45
+ heading = "#" * heading_level
46
+ prompts.map do |prompt|
47
+ lines = ["#{heading} Prompt #{prompt.index}"]
48
+ prompt_at = HumanDisplay.timestamp(prompt.at)
49
+ lines << "- At: #{HumanDisplay.markdown_literal(prompt_at)}" if prompt_at
50
+ lines << "- Refs: #{HumanDisplay.markdown_literal(HumanDisplay.refs(prompt.source_refs))}"
51
+ lines << ""
52
+ lines << HumanDisplay.markdown_block(prompt.text)
53
+ lines.join("\n")
54
+ end.join("\n\n")
55
+ end
56
+
57
+ def render_snapshot_prompts(prompts)
58
+ ["## User prompts", render_prompts(prompts, heading_level: 3)].join("\n\n")
59
+ end
60
+
61
+ def render_injected_context(contexts)
62
+ entries = contexts.map do |context|
63
+ lines = [
64
+ "### Injected #{HumanDisplay.markdown_literal(context.kind)}",
65
+ "- Bytes: #{HumanDisplay.markdown_literal(context.bytes)}",
66
+ "- Occurrences: #{HumanDisplay.markdown_literal(context.occurrences)}",
67
+ "- Refs: #{HumanDisplay.markdown_literal(HumanDisplay.refs(context.source_refs))}"
68
+ ]
69
+ if context.text
70
+ lines << ""
71
+ lines << HumanDisplay.markdown_block(context.text)
72
+ end
73
+ lines.join("\n")
74
+ end
75
+
76
+ ["## Injected context", entries.join("\n\n")].join("\n\n")
77
+ end
78
+
79
+ def render_session(snapshot)
80
+ lines = [
81
+ "## Session",
82
+ "- UID: #{HumanDisplay.markdown_literal(snapshot.session_uid)}",
83
+ "- Agent: #{HumanDisplay.markdown_literal(snapshot.agent)}"
84
+ ]
85
+ lines << "- Project path: #{HumanDisplay.markdown_literal(snapshot.project_path)}" if snapshot.project_path
86
+ captured_at = HumanDisplay.timestamp(snapshot.captured_at)
87
+ lines << "- Captured at: #{HumanDisplay.markdown_literal(captured_at)}" if captured_at
88
+ lines << "- Message count: #{HumanDisplay.markdown_literal(snapshot.message_count)}"
89
+ if snapshot.summary_metadata.any?
90
+ lines << "- Summary metadata: #{HumanDisplay.markdown_literal(attributes(snapshot.summary_metadata))}"
91
+ end
92
+ lines.join("\n")
93
+ end
94
+
95
+ def render_section(title, items)
96
+ return if items.empty?
97
+
98
+ if title == "Warnings"
99
+ return (["## #{title}"] + items.map { |warning| "- #{HumanDisplay.markdown_text(warning)}" }).join("\n")
100
+ end
101
+
102
+ (["## #{title}"] + items.map { |item| "- #{item_line(item)}" }).join("\n")
103
+ end
104
+
105
+ def item_line(item)
106
+ fragments = [base_item_text(item)]
107
+ attrs = attributes(item.attributes)
108
+ fragments << "(#{HumanDisplay.markdown_literal(attrs)})" unless attrs.empty?
109
+ fragments << HumanDisplay.markdown_literal("[#{item.evidence}]")
110
+ fragments << "refs #{HumanDisplay.markdown_literal(HumanDisplay.refs(item.source_refs))}"
111
+ fragments.join(" ")
112
+ end
113
+
114
+ def base_item_text(item)
115
+ return "#{HumanDisplay.markdown_text(item.label)}: #{HumanDisplay.markdown_text(item.detail)}" if item.detail
116
+
117
+ HumanDisplay.markdown_text(item.label)
118
+ end
119
+
120
+ def attributes(hash)
121
+ HumanDisplay.attributes(hash, inline_formatter: HumanDisplay.method(:text_inline))
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ module Serializer
7
+ module_function
8
+
9
+ def serialize(value)
10
+ case value
11
+ when nil, true, false, Integer
12
+ value
13
+ when Float
14
+ serialize_float(value)
15
+ when String
16
+ scrub_string(value)
17
+ when Symbol
18
+ scrub_string(value.to_s)
19
+ when Time
20
+ value.iso8601
21
+ when Array
22
+ value.map { |entry| serialize(entry) }
23
+ when Hash
24
+ serialize_hash(value)
25
+ else
26
+ return serialize_data(value) if data_object?(value)
27
+
28
+ scrub_string(value.to_s)
29
+ end
30
+ end
31
+
32
+ def scrub_string(value)
33
+ value.encode("UTF-8", invalid: :replace, undef: :replace)
34
+ end
35
+
36
+ def data_object?(value)
37
+ defined?(Data) && value.is_a?(Data)
38
+ end
39
+
40
+ def serialize_data(value)
41
+ value.members.to_h do |member|
42
+ [member.to_s, serialize_member(member, value.public_send(member))]
43
+ end
44
+ end
45
+
46
+ def serialize_hash(value)
47
+ normalized_hash_entries(value).each_with_object({}) do |(key, nested_value), serialized|
48
+ serialized[key] = serialize(nested_value)
49
+ end
50
+ end
51
+
52
+ def normalized_hash_entries(value)
53
+ entries = value.each_with_object([]) do |(key, nested_value), collected|
54
+ collected << [normalized_key(key), nested_value]
55
+ end
56
+ detect_duplicate_keys!(entries)
57
+ entries.sort_by(&:first)
58
+ end
59
+
60
+ def detect_duplicate_keys!(entries)
61
+ entries.group_by(&:first).sort_by(&:first).each do |key, grouped_entries|
62
+ next unless grouped_entries.length > 1
63
+
64
+ raise ArgumentError, "duplicate serialized key: #{safe_dump(key)}"
65
+ end
66
+ end
67
+
68
+ def serialize_member(member, value)
69
+ return serialize_timestamp(value) if %i[at captured_at].include?(member.to_sym)
70
+
71
+ serialize(value)
72
+ end
73
+
74
+ def serialize_timestamp(value)
75
+ case value
76
+ when nil
77
+ nil
78
+ when Time
79
+ value.iso8601
80
+ when String
81
+ scrub_string(value)
82
+ else
83
+ raise ArgumentError, "unsupported timestamp value: #{unsupported_value_label(value)}"
84
+ end
85
+ end
86
+
87
+ def serialize_float(value)
88
+ raise ArgumentError, "unsupported numeric value: #{unsupported_value_label(value)}" unless value.finite?
89
+
90
+ value
91
+ end
92
+
93
+ def normalized_key(key)
94
+ scrub_string(key.to_s)
95
+ end
96
+
97
+ def safe_dump(value)
98
+ case value
99
+ when String
100
+ scrub_string(value).dump
101
+ when Symbol
102
+ scrub_string(value.to_s).dump
103
+ when Float
104
+ return "NaN" if value.nan?
105
+ return "Infinity" if value.infinite? == 1
106
+ return "-Infinity" if value.infinite? == -1
107
+
108
+ value.to_s
109
+ else
110
+ scrub_string(value.inspect)
111
+ end
112
+ end
113
+
114
+ def unsupported_value_label(value)
115
+ klass = value.class
116
+ name = klass.name
117
+ return "(anonymous class)" if name.nil? || name.empty?
118
+
119
+ scrub_string(name)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ class Text
7
+ SECTION_ORDER = [
8
+ ["Goal", :goals],
9
+ ["Files", :files],
10
+ ["Documents", :documents],
11
+ ["Tool activity", :tool_activity],
12
+ ["Decisions", :decisions],
13
+ ["Terminology", :terms],
14
+ ["Constraints", :constraints],
15
+ ["Open questions", :open_questions],
16
+ ["Next actions", :next_actions],
17
+ ["Warnings", :warnings]
18
+ ].freeze
19
+ private_constant :SECTION_ORDER
20
+
21
+ def call(value)
22
+ if value.is_a?(Snapshot)
23
+ render_snapshot(value)
24
+ else
25
+ render_prompts(Array(value))
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def render_snapshot(snapshot)
32
+ sections = [render_session(snapshot)]
33
+ sections << render_snapshot_prompts(snapshot.prompts) if snapshot.prompts.any?
34
+ sections << render_injected_context(snapshot.injected_context) if snapshot.injected_context.any?
35
+
36
+ SECTION_ORDER.each do |title, field|
37
+ section = render_section(title, snapshot.public_send(field))
38
+ sections << section if section
39
+ end
40
+
41
+ sections.join("\n\n")
42
+ end
43
+
44
+ def render_snapshot_prompts(prompts)
45
+ ["User prompts", render_prompts(prompts)].join("\n\n")
46
+ end
47
+
48
+ def render_injected_context(contexts)
49
+ entries = contexts.map do |context|
50
+ lines = [
51
+ "Injected #{HumanDisplay.text_inline(context.kind)}",
52
+ "- Bytes: #{context.bytes}",
53
+ "- Occurrences: #{context.occurrences}",
54
+ "- Refs: #{HumanDisplay.refs(context.source_refs)}"
55
+ ]
56
+ if context.text
57
+ lines << "- Text:"
58
+ lines << HumanDisplay.text_block(context.text)
59
+ end
60
+ lines.join("\n")
61
+ end
62
+
63
+ ["Injected context", entries.join("\n\n")].join("\n\n")
64
+ end
65
+
66
+ def render_prompts(prompts)
67
+ prompts.map do |prompt|
68
+ lines = ["Prompt #{prompt.index}"]
69
+ prompt_at = HumanDisplay.timestamp(prompt.at)
70
+ lines << "- At: #{HumanDisplay.text_inline(prompt_at)}" if prompt_at
71
+ lines << "- Refs: #{HumanDisplay.refs(prompt.source_refs)}"
72
+ lines << HumanDisplay.text_block(prompt.text)
73
+ lines.join("\n")
74
+ end.join("\n\n")
75
+ end
76
+
77
+ def render_session(snapshot)
78
+ lines = [
79
+ "Session",
80
+ "- UID: #{HumanDisplay.text_inline(snapshot.session_uid)}",
81
+ "- Agent: #{HumanDisplay.text_inline(snapshot.agent)}"
82
+ ]
83
+ lines << "- Project path: #{HumanDisplay.text_inline(snapshot.project_path)}" if snapshot.project_path
84
+ captured_at = HumanDisplay.timestamp(snapshot.captured_at)
85
+ lines << "- Captured at: #{HumanDisplay.text_inline(captured_at)}" if captured_at
86
+ lines << "- Message count: #{snapshot.message_count}"
87
+ lines << "- Summary metadata: #{attributes(snapshot.summary_metadata)}" if snapshot.summary_metadata.any?
88
+ lines.join("\n")
89
+ end
90
+
91
+ def render_section(title, items)
92
+ return if items.empty?
93
+
94
+ if title == "Warnings"
95
+ return ([title] + items.map { |warning| "- #{HumanDisplay.text_inline(warning)}" }).join("\n")
96
+ end
97
+
98
+ ([title] + items.map { |item| "- #{item_line(item)}" }).join("\n")
99
+ end
100
+
101
+ def item_line(item)
102
+ fragments = [base_item_text(item)]
103
+ attrs = attributes(item.attributes)
104
+ fragments << "(#{attrs})" unless attrs.empty?
105
+ fragments << "[#{item.evidence}]"
106
+ fragments << "refs #{HumanDisplay.refs(item.source_refs)}"
107
+ fragments.join(" ")
108
+ end
109
+
110
+ def base_item_text(item)
111
+ return "#{HumanDisplay.text_inline(item.label)}: #{HumanDisplay.text_inline(item.detail)}" if item.detail
112
+
113
+ HumanDisplay.text_inline(item.label)
114
+ end
115
+
116
+ def attributes(hash)
117
+ HumanDisplay.attributes(hash, inline_formatter: HumanDisplay.method(:text_inline))
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class SemanticCategories
6
+ Category = Data.define(
7
+ :external_key,
8
+ :internal_kind,
9
+ :snapshot_field,
10
+ :prompt_description,
11
+ :item_shape
12
+ )
13
+
14
+ ALL = [
15
+ Category.new(
16
+ external_key: "goals",
17
+ internal_kind: :goal,
18
+ snapshot_field: :goals,
19
+ prompt_description: "stated goals or desired outcomes from the conversation",
20
+ item_shape: :text
21
+ ),
22
+ Category.new(
23
+ external_key: "decisions",
24
+ internal_kind: :decision,
25
+ snapshot_field: :decisions,
26
+ prompt_description: "decisions the participants have already made",
27
+ item_shape: :text
28
+ ),
29
+ Category.new(
30
+ external_key: "terms",
31
+ internal_kind: :term,
32
+ snapshot_field: :terms,
33
+ prompt_description: "project-specific terms with their definitions",
34
+ item_shape: :term
35
+ ),
36
+ Category.new(
37
+ external_key: "constraints",
38
+ internal_kind: :constraint,
39
+ snapshot_field: :constraints,
40
+ prompt_description: "limits, requirements, or non-negotiables",
41
+ item_shape: :text
42
+ ),
43
+ Category.new(
44
+ external_key: "open_questions",
45
+ internal_kind: :open_question,
46
+ snapshot_field: :open_questions,
47
+ prompt_description: "questions that are still unresolved",
48
+ item_shape: :text
49
+ ),
50
+ Category.new(
51
+ external_key: "next_actions",
52
+ internal_kind: :next_action,
53
+ snapshot_field: :next_actions,
54
+ prompt_description: "concrete follow-up actions that someone should take",
55
+ item_shape: :text
56
+ )
57
+ ].freeze
58
+ EXTERNAL_KEYS = ALL.map(&:external_key).freeze
59
+ SNAPSHOT_FIELDS = ALL.map(&:snapshot_field).uniq.freeze
60
+ EXTERNAL_INDEX = ALL.to_h { |category| [category.external_key, category] }.freeze
61
+ INTERNAL_INDEX = ALL.to_h { |category| [category.internal_kind, category] }.freeze
62
+
63
+ private_constant :EXTERNAL_KEYS, :SNAPSHOT_FIELDS, :EXTERNAL_INDEX, :INTERNAL_INDEX
64
+
65
+ class << self
66
+ def all
67
+ ALL
68
+ end
69
+
70
+ def external_keys
71
+ EXTERNAL_KEYS
72
+ end
73
+
74
+ def snapshot_fields
75
+ SNAPSHOT_FIELDS
76
+ end
77
+
78
+ def lookup(identifier)
79
+ case identifier
80
+ when String
81
+ EXTERNAL_INDEX[identifier]
82
+ when Symbol
83
+ INTERNAL_INDEX[identifier]
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end