codex-notify 1.0.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 +50 -0
- data/LICENSE +21 -0
- data/README.md +782 -0
- data/bin/codex-notify +6 -0
- data/bin/codex-notify-hook +6 -0
- data/lib/codex_notify/cli.rb +112 -0
- data/lib/codex_notify/config.rb +113 -0
- data/lib/codex_notify/config_diagnostics.rb +61 -0
- data/lib/codex_notify/config_migrator.rb +148 -0
- data/lib/codex_notify/config_support.rb +157 -0
- data/lib/codex_notify/destination_name.rb +18 -0
- data/lib/codex_notify/destination_resolver.rb +68 -0
- data/lib/codex_notify/durable_slack_publisher.rb +70 -0
- data/lib/codex_notify/env_source_loader.rb +91 -0
- data/lib/codex_notify/hook_cli.rb +90 -0
- data/lib/codex_notify/hook_config.rb +110 -0
- data/lib/codex_notify/hook_event.rb +16 -0
- data/lib/codex_notify/hook_formatter.rb +61 -0
- data/lib/codex_notify/hook_input_validator.rb +241 -0
- data/lib/codex_notify/hook_runner.rb +181 -0
- data/lib/codex_notify/hook_store.rb +133 -0
- data/lib/codex_notify/hook_thread_publisher.rb +107 -0
- data/lib/codex_notify/log_event_parser.rb +230 -0
- data/lib/codex_notify/message_formatter.rb +115 -0
- data/lib/codex_notify/outbox_commands.rb +43 -0
- data/lib/codex_notify/secret_protection.rb +37 -0
- data/lib/codex_notify/session_log.rb +63 -0
- data/lib/codex_notify/slack_client.rb +117 -0
- data/lib/codex_notify/slack_delivery_worker.rb +269 -0
- data/lib/codex_notify/slack_outbox.rb +206 -0
- data/lib/codex_notify/stream_processor.rb +141 -0
- data/lib/codex_notify/trusted_config_loader.rb +159 -0
- data/lib/codex_notify/version.rb +5 -0
- data/lib/codex_notify.rb +36 -0
- metadata +96 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'hook_config'
|
|
4
|
+
require_relative 'hook_store'
|
|
5
|
+
require_relative 'hook_formatter'
|
|
6
|
+
require_relative 'hook_thread_publisher'
|
|
7
|
+
require_relative 'slack_client'
|
|
8
|
+
require_relative 'slack_outbox'
|
|
9
|
+
|
|
10
|
+
module CodexNotify
|
|
11
|
+
class HookRunner
|
|
12
|
+
RESET_THREAD_PROMPT = '---'
|
|
13
|
+
SESSION_RESET_SOURCES = %w[startup clear].freeze
|
|
14
|
+
INTERNAL_AMBIENT_SUGGESTIONS_PROMPTS = [
|
|
15
|
+
[
|
|
16
|
+
'# Overview',
|
|
17
|
+
'Generate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this local project',
|
|
18
|
+
"Suggest actionable tasks that they would actually act on/click"
|
|
19
|
+
].freeze,
|
|
20
|
+
[
|
|
21
|
+
'You are an expert at upholding safety and compliance standards for Codex ambient suggestions.',
|
|
22
|
+
'ambient suggestion candidates',
|
|
23
|
+
'## 1. Policies to always exclude'
|
|
24
|
+
].freeze
|
|
25
|
+
].freeze
|
|
26
|
+
INTERNAL_AMBIENT_SUGGESTIONS_RESPONSES = [
|
|
27
|
+
[
|
|
28
|
+
'exclude',
|
|
29
|
+
'suggestion_id',
|
|
30
|
+
'Only output the JSON object'
|
|
31
|
+
].freeze,
|
|
32
|
+
[
|
|
33
|
+
'suggestions to exclude',
|
|
34
|
+
'reason',
|
|
35
|
+
'applicable policy'
|
|
36
|
+
].freeze
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(token:, channel:, user_name:, title:, state_file:, mode: HookConfig::DEFAULT_MODE, stdout: $stdout,
|
|
40
|
+
client: nil, store: nil, outbox: nil, outbox_dir: nil)
|
|
41
|
+
@store = store || HookStore.new(state_file)
|
|
42
|
+
client ||= SlackClient.new(token:, channel:)
|
|
43
|
+
outbox ||= SlackOutbox.new(outbox_dir || "#{state_file}.outbox")
|
|
44
|
+
@publisher = HookThreadPublisher.new(client:, store: @store, outbox:, channel:)
|
|
45
|
+
@user_name = user_name
|
|
46
|
+
@title = title
|
|
47
|
+
@mode = mode
|
|
48
|
+
@stdout = stdout
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run(event:)
|
|
52
|
+
@store.with_session_lock(event.session_id) do
|
|
53
|
+
case event.name
|
|
54
|
+
when 'SessionStart'
|
|
55
|
+
handle_session_start(event)
|
|
56
|
+
when 'UserPromptSubmit'
|
|
57
|
+
handle_user_prompt_submit(event)
|
|
58
|
+
when 'PreToolUse'
|
|
59
|
+
handle_pre_tool_use(event)
|
|
60
|
+
when 'PostToolUse'
|
|
61
|
+
handle_post_tool_use(event)
|
|
62
|
+
when 'PermissionRequest'
|
|
63
|
+
handle_permission_request(event)
|
|
64
|
+
when 'Stop'
|
|
65
|
+
handle_stop(event)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result = @publisher.drain
|
|
70
|
+
result&.failed&.any? || result&.needs_review&.any? ? 1 : 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def session_root_message(event)
|
|
76
|
+
title = @title || "Codex session: #{File.basename(event.cwd)}"
|
|
77
|
+
HookFormatter.session_root_message(event, title:, user_name: @user_name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def handle_session_start(event)
|
|
81
|
+
if reset_thread_on_session_start?(event)
|
|
82
|
+
session_id = event.session_id
|
|
83
|
+
@publisher.reset_thread(session_id:)
|
|
84
|
+
@store.clear_suppressed_session(session_id)
|
|
85
|
+
end
|
|
86
|
+
@publisher.ensure_thread(session_id: event.session_id, root_message: session_root_message(event)) if debug?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def handle_user_prompt_submit(event)
|
|
90
|
+
prompt = event.prompt
|
|
91
|
+
session_id = event.session_id
|
|
92
|
+
if normal? && internal_ambient_suggestions_prompt?(prompt)
|
|
93
|
+
@store.suppress_session(session_id, 'internal_ambient_suggestions')
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@store.clear_suppressed_session(session_id)
|
|
98
|
+
|
|
99
|
+
if reset_thread_prompt?(prompt)
|
|
100
|
+
@publisher.reset_thread(session_id:)
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
message = HookFormatter.prompt_message(event, user_name: @user_name)
|
|
105
|
+
@publisher.publish_root_or_reply(session_id:, message:)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_pre_tool_use(event)
|
|
109
|
+
return unless debug?
|
|
110
|
+
|
|
111
|
+
@publisher.publish_reply(
|
|
112
|
+
session_id: event.session_id,
|
|
113
|
+
message: HookFormatter.pre_tool_message(event),
|
|
114
|
+
recovery_root_message: session_root_message(event)
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def handle_post_tool_use(event)
|
|
119
|
+
return unless debug?
|
|
120
|
+
|
|
121
|
+
@publisher.publish_reply(
|
|
122
|
+
session_id: event.session_id,
|
|
123
|
+
message: HookFormatter.post_tool_message(event),
|
|
124
|
+
recovery_root_message: session_root_message(event)
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle_permission_request(event)
|
|
129
|
+
message = HookFormatter.permission_request_message(event)
|
|
130
|
+
@publisher.publish_root_or_reply(session_id: event.session_id, message:)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_stop(event)
|
|
134
|
+
session_id = event.session_id
|
|
135
|
+
return if normal? && @store.suppressed_session?(session_id)
|
|
136
|
+
|
|
137
|
+
message = event.assistant_message
|
|
138
|
+
return if normal? && internal_ambient_suggestions_response?(message)
|
|
139
|
+
|
|
140
|
+
unless message.nil? || message.to_s.empty?
|
|
141
|
+
@publisher.publish_reply(
|
|
142
|
+
session_id:,
|
|
143
|
+
message: HookFormatter.assistant_message(event),
|
|
144
|
+
recovery_root_message: session_root_message(event)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def reset_thread_prompt?(prompt)
|
|
150
|
+
prompt.to_s.strip == RESET_THREAD_PROMPT
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def reset_thread_on_session_start?(event)
|
|
154
|
+
SESSION_RESET_SOURCES.include?(event.source.to_s)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def internal_ambient_suggestions_prompt?(prompt)
|
|
158
|
+
text = prompt.to_s
|
|
159
|
+
INTERNAL_AMBIENT_SUGGESTIONS_PROMPTS.any? do |markers|
|
|
160
|
+
markers.all? { |marker| text.include?(marker) }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def internal_ambient_suggestions_response?(message)
|
|
165
|
+
text = message.to_s
|
|
166
|
+
return false if text.empty?
|
|
167
|
+
|
|
168
|
+
INTERNAL_AMBIENT_SUGGESTIONS_RESPONSES.any? do |markers|
|
|
169
|
+
markers.all? { |marker| text.include?(marker) }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def normal?
|
|
174
|
+
@mode == 'normal'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def debug?
|
|
178
|
+
@mode == 'debug'
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'tmpdir'
|
|
8
|
+
|
|
9
|
+
module CodexNotify
|
|
10
|
+
class HookStore
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = Pathname(path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def thread_ts_for(session_id)
|
|
16
|
+
state.dig('threads', session_id.to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def save_thread_ts(session_id, thread_ts)
|
|
20
|
+
update_state do |data|
|
|
21
|
+
data['threads'][session_id.to_s] = thread_ts.to_s
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def clear_thread(session_id)
|
|
26
|
+
update_state do |data|
|
|
27
|
+
data['threads'].delete(session_id.to_s)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def suppress_session(session_id, reason)
|
|
32
|
+
update_state do |data|
|
|
33
|
+
key = session_id.to_s
|
|
34
|
+
data['threads'].delete(key)
|
|
35
|
+
data['generations'][key] = data['generations'].fetch(key, 0).to_i + 1
|
|
36
|
+
data['suppressed_sessions'][key] = {
|
|
37
|
+
'reason' => reason.to_s,
|
|
38
|
+
'suppressed_at' => Time.now.utc.iso8601
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def suppressed_session?(session_id)
|
|
44
|
+
state.fetch('suppressed_sessions', {}).key?(session_id.to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clear_suppressed_session(session_id)
|
|
48
|
+
update_state do |data|
|
|
49
|
+
data['suppressed_sessions'].delete(session_id.to_s)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def generation_for(session_id)
|
|
54
|
+
state.fetch('generations', {}).fetch(session_id.to_s, 0).to_i
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def advance_generation(session_id)
|
|
58
|
+
update_state do |data|
|
|
59
|
+
key = session_id.to_s
|
|
60
|
+
data['threads'].delete(key)
|
|
61
|
+
data['generations'][key] = data['generations'].fetch(key, 0).to_i + 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Keep each hook event for a session atomic, including any Slack calls made
|
|
66
|
+
# between reading and updating the state file. Callers must acquire this
|
|
67
|
+
# session lock before an operation that takes the state lock.
|
|
68
|
+
def with_session_lock(session_id)
|
|
69
|
+
digest = Digest::SHA256.hexdigest(session_id.to_s)
|
|
70
|
+
with_file_lock(session_lock_dir.join("#{digest}.lock")) { yield }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def state
|
|
76
|
+
return default_state unless @path.exist?
|
|
77
|
+
|
|
78
|
+
normalize_state(JSON.parse(@path.read))
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
default_state
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def default_state
|
|
84
|
+
{ 'threads' => {}, 'suppressed_sessions' => {}, 'generations' => {} }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_state(data)
|
|
88
|
+
data = {} unless data.is_a?(Hash)
|
|
89
|
+
defaults = default_state
|
|
90
|
+
defaults.merge(data) do |_key, default_value, value|
|
|
91
|
+
value.is_a?(Hash) ? value : default_value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def update_state
|
|
96
|
+
with_file_lock(state_lock_path) do
|
|
97
|
+
data = state
|
|
98
|
+
yield(data)
|
|
99
|
+
write_state(data)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def state_lock_path
|
|
104
|
+
@path.dirname.join("#{@path.basename}.lock")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def session_lock_dir
|
|
108
|
+
@path.dirname.join("#{@path.basename}.locks")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def with_file_lock(path)
|
|
112
|
+
path.dirname.mkpath
|
|
113
|
+
File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
|
|
114
|
+
locked = false
|
|
115
|
+
raise IOError, "could not lock #{path}" unless file.flock(File::LOCK_EX)
|
|
116
|
+
|
|
117
|
+
locked = true
|
|
118
|
+
yield
|
|
119
|
+
ensure
|
|
120
|
+
file.flock(File::LOCK_UN) if locked
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def write_state(data)
|
|
125
|
+
@path.dirname.mkpath
|
|
126
|
+
tmp = @path.dirname.join(".#{@path.basename}.tmp-#{Process.pid}")
|
|
127
|
+
tmp.write(JSON.pretty_generate(data))
|
|
128
|
+
File.rename(tmp, @path)
|
|
129
|
+
ensure
|
|
130
|
+
tmp.delete if defined?(tmp) && tmp.exist?
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'message_formatter'
|
|
4
|
+
require_relative 'slack_client'
|
|
5
|
+
require_relative 'durable_slack_publisher'
|
|
6
|
+
|
|
7
|
+
module CodexNotify
|
|
8
|
+
class HookThreadPublisher
|
|
9
|
+
STALE_THREAD_ERROR_CODES = %w[thread_not_found message_not_found invalid_ts].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(client:, store:, outbox: nil, channel: nil)
|
|
12
|
+
@client = client
|
|
13
|
+
@store = store
|
|
14
|
+
@durable = DurableSlackPublisher.new(client:, store:, outbox:, channel:) if outbox
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :client
|
|
18
|
+
|
|
19
|
+
def ensure_thread(session_id:, root_message:)
|
|
20
|
+
return @durable.ensure_thread(key: session_id, root_message:) if @durable
|
|
21
|
+
|
|
22
|
+
thread_ts = thread_for(session_id)
|
|
23
|
+
return thread_ts if thread_ts
|
|
24
|
+
|
|
25
|
+
create_thread(session_id:, root_message:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def publish_root_or_reply(session_id:, message:)
|
|
29
|
+
return @durable.publish_root_or_reply(key: session_id, message:) if @durable
|
|
30
|
+
|
|
31
|
+
thread_ts = thread_for(session_id)
|
|
32
|
+
return create_thread(session_id:, root_message: message) unless thread_ts
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
post_message(message, thread_ts:)
|
|
36
|
+
thread_ts
|
|
37
|
+
rescue SlackClient::Error => e
|
|
38
|
+
raise unless stale_thread_error?(e)
|
|
39
|
+
|
|
40
|
+
reset_thread(session_id:)
|
|
41
|
+
create_thread(session_id:, root_message: message)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def publish_reply(session_id:, message:, recovery_root_message:)
|
|
46
|
+
if @durable
|
|
47
|
+
return @durable.publish_reply(key: session_id, message:, recovery_root_message:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
thread_ts = thread_for(session_id)
|
|
51
|
+
return unless thread_ts
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
post_message(message, thread_ts:)
|
|
55
|
+
thread_ts
|
|
56
|
+
rescue SlackClient::Error => e
|
|
57
|
+
raise unless stale_thread_error?(e)
|
|
58
|
+
|
|
59
|
+
reset_thread(session_id:)
|
|
60
|
+
recovered_thread_ts = create_thread(session_id:, root_message: recovery_root_message)
|
|
61
|
+
return unless recovered_thread_ts
|
|
62
|
+
|
|
63
|
+
post_message(message, thread_ts: recovered_thread_ts)
|
|
64
|
+
recovered_thread_ts
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset_thread(session_id:)
|
|
69
|
+
return @durable.reset(key: session_id) if @durable
|
|
70
|
+
|
|
71
|
+
@store.clear_thread(session_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def drain
|
|
75
|
+
@durable&.drain
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def thread_for(session_id)
|
|
81
|
+
@store.thread_ts_for(session_id)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def create_thread(session_id:, root_message:)
|
|
85
|
+
return if root_message.nil? || root_message.body.empty?
|
|
86
|
+
|
|
87
|
+
parts = MessageFormatter.chunks(root_message).to_a
|
|
88
|
+
response = @client.post(parts.shift)
|
|
89
|
+
thread_ts = response.fetch('ts').to_s
|
|
90
|
+
@store.save_thread_ts(session_id, thread_ts)
|
|
91
|
+
post_parts(parts, thread_ts:)
|
|
92
|
+
thread_ts
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def post_message(message, thread_ts:)
|
|
96
|
+
post_parts(MessageFormatter.chunks(message), thread_ts:)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def post_parts(parts, thread_ts:)
|
|
100
|
+
parts.each { |part| @client.post(part, thread_ts:) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stale_thread_error?(error)
|
|
104
|
+
STALE_THREAD_ERROR_CODES.include?(error.error_code)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module CodexNotify
|
|
6
|
+
module LogEventParser
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def as_text(value)
|
|
10
|
+
return '' if value.nil?
|
|
11
|
+
return value if value.is_a?(String)
|
|
12
|
+
return JSON.generate(value, ascii_only: false) if value.is_a?(Hash) || value.is_a?(Array)
|
|
13
|
+
|
|
14
|
+
value.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def pretty_json(value)
|
|
18
|
+
JSON.pretty_generate(value, ascii_only: false)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
as_text(value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def format_tool_payload(payload)
|
|
24
|
+
command = payload['command'] || payload['cmd'] || payload['argv']
|
|
25
|
+
stdout = payload['stdout']
|
|
26
|
+
stderr = payload['stderr']
|
|
27
|
+
exit_code = payload['exit_code']
|
|
28
|
+
|
|
29
|
+
if command.nil? && payload['info'].is_a?(Hash)
|
|
30
|
+
info = payload['info']
|
|
31
|
+
command = info['command'] || info['cmd'] || info['argv']
|
|
32
|
+
stdout ||= info['stdout']
|
|
33
|
+
stderr ||= info['stderr']
|
|
34
|
+
exit_code = info['exit_code'] if exit_code.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if !command.nil? || !stdout.nil? || !stderr.nil? || !exit_code.nil?
|
|
38
|
+
command_text = command.is_a?(Array) ? command.map(&:to_s).join(' ') : (command || '').to_s
|
|
39
|
+
parts = []
|
|
40
|
+
parts << "$ #{command_text}" unless command_text.empty?
|
|
41
|
+
parts << "[exit_code] #{exit_code}" unless exit_code.nil?
|
|
42
|
+
parts << "\n[stdout]\n#{stdout}" if stdout && !stdout.empty?
|
|
43
|
+
parts << "\n[stderr]\n#{stderr}" if stderr && !stderr.empty?
|
|
44
|
+
rendered = parts.join("\n").strip
|
|
45
|
+
return rendered.empty? ? pretty_json(payload) : rendered
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
%w[tool_name name tool function_name].each do |key|
|
|
49
|
+
next unless payload[key]
|
|
50
|
+
|
|
51
|
+
tool_name = payload[key]
|
|
52
|
+
args = payload['arguments'] || payload['args'] || payload['input']
|
|
53
|
+
result = payload['result'] || payload['output']
|
|
54
|
+
parts = ["[tool] #{tool_name}"]
|
|
55
|
+
parts << "[args]\n#{pretty_json(args)}" unless args.nil?
|
|
56
|
+
parts << "[result]\n#{pretty_json(result)}" unless result.nil?
|
|
57
|
+
return parts.join("\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
pretty_json(payload)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tool_event_type?(event_type)
|
|
64
|
+
return false if event_type.nil? || event_type.to_s.empty?
|
|
65
|
+
|
|
66
|
+
%w[
|
|
67
|
+
command_execution
|
|
68
|
+
shell_command
|
|
69
|
+
tool_call
|
|
70
|
+
tool_result
|
|
71
|
+
tool
|
|
72
|
+
mcp_tool_call
|
|
73
|
+
mcp_tool_result
|
|
74
|
+
mcp_call
|
|
75
|
+
mcp_result
|
|
76
|
+
sandbox_command
|
|
77
|
+
].include?(event_type.to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract_events(obj)
|
|
81
|
+
events = []
|
|
82
|
+
|
|
83
|
+
if obj.is_a?(Array)
|
|
84
|
+
obj.each { |item| events.concat(extract_events(item)) }
|
|
85
|
+
return events
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return events unless obj.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
object_type = obj['type']
|
|
91
|
+
if %w[input_text output_text].include?(object_type)
|
|
92
|
+
text = as_text(obj['text'] || obj['content'])
|
|
93
|
+
unless text.empty?
|
|
94
|
+
kind = object_type == 'input_text' ? 'user' : 'assistant'
|
|
95
|
+
events << [kind, text, object_type]
|
|
96
|
+
end
|
|
97
|
+
return events
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
payload = obj['payload']
|
|
101
|
+
if payload.is_a?(Hash)
|
|
102
|
+
payload_type = payload['type']
|
|
103
|
+
|
|
104
|
+
if tool_event_type?(payload_type)
|
|
105
|
+
events << ['tool', format_tool_payload(payload), 'tool']
|
|
106
|
+
return events
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if %w[input_text output_text].include?(payload_type)
|
|
110
|
+
text = as_text(payload['text'] || payload['content'])
|
|
111
|
+
unless text.empty?
|
|
112
|
+
kind = payload_type == 'input_text' ? 'user' : 'assistant'
|
|
113
|
+
events << [kind, text, payload_type]
|
|
114
|
+
end
|
|
115
|
+
return events
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if payload_type == 'message'
|
|
119
|
+
role = payload['role']
|
|
120
|
+
content = payload['content'] || payload['parts']
|
|
121
|
+
|
|
122
|
+
if content.is_a?(Array)
|
|
123
|
+
content.each do |part|
|
|
124
|
+
next unless part.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
part_type = part['type']
|
|
127
|
+
if %w[input_text output_text].include?(part_type)
|
|
128
|
+
text = as_text(part['text'] || part['content'])
|
|
129
|
+
unless text.empty?
|
|
130
|
+
kind = part_type == 'input_text' ? 'user' : 'assistant'
|
|
131
|
+
events << [kind, text, part_type]
|
|
132
|
+
end
|
|
133
|
+
elsif tool_event_type?(part_type)
|
|
134
|
+
events << ['tool', format_tool_payload(part), 'tool']
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
text = as_text(payload['text'])
|
|
139
|
+
if !text.empty? && %w[user assistant].include?(role)
|
|
140
|
+
inferred = role == 'user' ? 'input_text' : 'output_text'
|
|
141
|
+
events << [role, text, inferred]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
return events
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if payload_type == 'user_message'
|
|
148
|
+
text = as_text(payload['message'] || payload['text'])
|
|
149
|
+
events << ['user', text, 'input_text'] unless text.empty?
|
|
150
|
+
return events
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if payload_type == 'agent_message'
|
|
154
|
+
text = as_text(payload['message'] || payload['text'])
|
|
155
|
+
events << ['assistant', text, 'output_text'] unless text.empty?
|
|
156
|
+
return events
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if payload_type == 'task_complete'
|
|
160
|
+
text = as_text(payload['last_agent_message'])
|
|
161
|
+
events << ['assistant', text, 'output_text'] unless text.empty?
|
|
162
|
+
return events
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
nested_message = payload['message']
|
|
166
|
+
if nested_message.is_a?(Hash) || nested_message.is_a?(Array)
|
|
167
|
+
events.concat(extract_events(nested_message))
|
|
168
|
+
return events
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
%w[tool tool_call tool_result command_execution command].each do |key|
|
|
172
|
+
nested = payload[key]
|
|
173
|
+
next unless nested.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
nested_type = nested['type'] || key
|
|
176
|
+
if tool_event_type?(nested_type) || %w[command_execution command].include?(key)
|
|
177
|
+
events << ['tool', format_tool_payload(nested), 'tool']
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
%w[message item response_item response_message data].each do |key|
|
|
183
|
+
nested = obj[key]
|
|
184
|
+
events.concat(extract_events(nested)) if nested.is_a?(Hash) || nested.is_a?(Array)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
events
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def extract_session_id(obj)
|
|
191
|
+
return nil if obj.nil?
|
|
192
|
+
|
|
193
|
+
if obj.is_a?(Hash)
|
|
194
|
+
if obj['type'] == 'session_meta'
|
|
195
|
+
payload = obj['payload']
|
|
196
|
+
if payload.is_a?(Hash)
|
|
197
|
+
value = payload['id']
|
|
198
|
+
return value.to_s unless value.nil? || value.to_s.empty?
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
%w[
|
|
203
|
+
session_id
|
|
204
|
+
sessionId
|
|
205
|
+
conversation_id
|
|
206
|
+
conversationId
|
|
207
|
+
chat_id
|
|
208
|
+
chatId
|
|
209
|
+
thread_id
|
|
210
|
+
threadId
|
|
211
|
+
].each do |key|
|
|
212
|
+
value = obj[key]
|
|
213
|
+
return value.to_s unless value.nil? || value.to_s.empty?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
obj.each_value do |value|
|
|
217
|
+
session_id = extract_session_id(value)
|
|
218
|
+
return session_id if session_id
|
|
219
|
+
end
|
|
220
|
+
elsif obj.is_a?(Array)
|
|
221
|
+
obj.each do |value|
|
|
222
|
+
session_id = extract_session_id(value)
|
|
223
|
+
return session_id if session_id
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|