claude_agent 0.7.8 → 0.7.10

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.
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Tracks cumulative usage statistics across multiple conversation turns
5
+ #
6
+ # Token counts are summed across all turns. Cost and turn count reflect
7
+ # the session-cumulative values from the most recent result (the CLI
8
+ # already accumulates these across the session).
9
+ #
10
+ # @example Via Client
11
+ # ClaudeAgent::Client.open do |client|
12
+ # client.send_message("Hello")
13
+ # client.receive_response.each { |m| }
14
+ #
15
+ # client.send_message("Follow up")
16
+ # client.receive_response.each { |m| }
17
+ #
18
+ # usage = client.cumulative_usage
19
+ # puts "Tokens: #{usage.input_tokens} in / #{usage.output_tokens} out"
20
+ # puts "Cost: $#{usage.total_cost_usd}"
21
+ # puts "Turns: #{usage.num_turns}"
22
+ # end
23
+ #
24
+ # @example Standalone
25
+ # tracker = ClaudeAgent::CumulativeUsage.new
26
+ # messages.each { |msg| tracker.track(msg) }
27
+ # puts tracker.to_h
28
+ #
29
+ class CumulativeUsage
30
+ attr_reader :input_tokens, :output_tokens,
31
+ :cache_read_input_tokens, :cache_creation_input_tokens,
32
+ :total_cost_usd, :num_turns,
33
+ :duration_ms, :duration_api_ms
34
+
35
+ def initialize
36
+ @mutex = Mutex.new
37
+ @input_tokens = 0
38
+ @output_tokens = 0
39
+ @cache_read_input_tokens = 0
40
+ @cache_creation_input_tokens = 0
41
+ @total_cost_usd = 0.0
42
+ @num_turns = 0
43
+ @duration_ms = 0
44
+ @duration_api_ms = 0
45
+ end
46
+
47
+ # Update cumulative usage from a message
48
+ #
49
+ # Only processes {ResultMessage} instances; other message types are ignored.
50
+ #
51
+ # @param message [Object] Any message object
52
+ # @return [void]
53
+ def track(message)
54
+ return unless message.is_a?(ResultMessage)
55
+
56
+ @mutex.synchronize do
57
+ if message.usage
58
+ @input_tokens += message.usage[:input_tokens].to_i
59
+ @output_tokens += message.usage[:output_tokens].to_i
60
+ @cache_read_input_tokens += message.usage[:cache_read_input_tokens].to_i
61
+ @cache_creation_input_tokens += message.usage[:cache_creation_input_tokens].to_i
62
+ end
63
+
64
+ # Cost and turn count are session-cumulative from the CLI
65
+ @total_cost_usd = message.total_cost_usd if message.total_cost_usd
66
+ @num_turns = message.num_turns if message.num_turns
67
+
68
+ # Durations are per-turn, sum them
69
+ @duration_ms += message.duration_ms.to_i
70
+ @duration_api_ms += message.duration_api_ms.to_i
71
+ end
72
+ end
73
+
74
+ # Reset all counters to zero
75
+ #
76
+ # @return [void]
77
+ def reset!
78
+ @mutex.synchronize do
79
+ @input_tokens = 0
80
+ @output_tokens = 0
81
+ @cache_read_input_tokens = 0
82
+ @cache_creation_input_tokens = 0
83
+ @total_cost_usd = 0.0
84
+ @num_turns = 0
85
+ @duration_ms = 0
86
+ @duration_api_ms = 0
87
+ end
88
+ end
89
+
90
+ # @return [Hash] All tracked fields as a hash
91
+ def to_h
92
+ @mutex.synchronize do
93
+ {
94
+ input_tokens: @input_tokens,
95
+ output_tokens: @output_tokens,
96
+ cache_read_input_tokens: @cache_read_input_tokens,
97
+ cache_creation_input_tokens: @cache_creation_input_tokens,
98
+ total_cost_usd: @total_cost_usd,
99
+ num_turns: @num_turns,
100
+ duration_ms: @duration_ms,
101
+ duration_api_ms: @duration_api_ms
102
+ }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Dispatches typed events as messages flow through a conversation turn.
5
+ #
6
+ # Register handlers for specific events instead of writing `case` statements
7
+ # over raw message types. Use standalone or via {Client#on}.
8
+ #
9
+ # @example Standalone
10
+ # handler = ClaudeAgent::EventHandler.new
11
+ # handler.on_text { |text| print text }
12
+ # handler.on_tool_use { |tool| puts "Using: #{tool.display_label}" }
13
+ # handler.on_result { |result| puts "Cost: $#{result.total_cost_usd}" }
14
+ #
15
+ # client.receive_response.each { |msg| handler.handle(msg) }
16
+ #
17
+ # @example Via Client
18
+ # client.on_text { |text| print text }
19
+ # client.on_tool_use { |tool| puts tool.display_label }
20
+ # turn = client.send_and_receive("Fix the bug")
21
+ #
22
+ # @example Chaining
23
+ # handler = ClaudeAgent::EventHandler.new
24
+ # .on_text { |text| print text }
25
+ # .on_result { |r| puts "\nDone!" }
26
+ #
27
+ class EventHandler
28
+ # Events:
29
+ # :message — every message (catch-all)
30
+ # :text — AssistantMessage text content
31
+ # :thinking — AssistantMessage thinking content
32
+ # :tool_use — ToolUseBlock or ServerToolUseBlock
33
+ # :tool_result — ToolResultBlock or ServerToolResultBlock, paired with original tool_use
34
+ # :result — ResultMessage (end of turn)
35
+
36
+ def initialize
37
+ @handlers = Hash.new { |h, k| h[k] = [] }
38
+ @pending_tool_uses = {}
39
+ end
40
+
41
+ # Register a handler for an event
42
+ #
43
+ # @param event [Symbol] Event name
44
+ # @yield Event-specific arguments
45
+ # @return [self]
46
+ def on(event, &block)
47
+ @handlers[event] << block
48
+ self
49
+ end
50
+
51
+ # @!method on_message(&block)
52
+ # Register a handler for every message
53
+ # @yield [message] Any message object
54
+ # @return [self]
55
+
56
+ # @!method on_text(&block)
57
+ # Register a handler for assistant text content
58
+ # @yield [String] Text from the AssistantMessage
59
+ # @return [self]
60
+
61
+ # @!method on_thinking(&block)
62
+ # Register a handler for assistant thinking content
63
+ # @yield [String] Thinking from the AssistantMessage
64
+ # @return [self]
65
+
66
+ # @!method on_tool_use(&block)
67
+ # Register a handler for tool use requests
68
+ # @yield [ToolUseBlock, ServerToolUseBlock] The tool use block
69
+ # @return [self]
70
+
71
+ # @!method on_tool_result(&block)
72
+ # Register a handler for tool results, paired with the original request
73
+ # @yield [ToolResultBlock, ToolUseBlock|nil] Result block and matched tool use
74
+ # @return [self]
75
+
76
+ # @!method on_result(&block)
77
+ # Register a handler for the final ResultMessage
78
+ # @yield [ResultMessage] The result
79
+ # @return [self]
80
+
81
+ %i[message text thinking tool_use tool_result result].each do |event|
82
+ define_method(:"on_#{event}") { |&block| on(event, &block) }
83
+ end
84
+
85
+ # Dispatch a message to registered handlers
86
+ #
87
+ # @param message [Message] Any SDK message
88
+ # @return [void]
89
+ def handle(message)
90
+ emit(:message, message)
91
+
92
+ case message
93
+ when AssistantMessage
94
+ handle_assistant(message)
95
+ when UserMessage, UserMessageReplay
96
+ handle_user(message)
97
+ when ResultMessage
98
+ emit(:result, message)
99
+ end
100
+ end
101
+
102
+ # Clear turn-level tracking state (pending tool uses)
103
+ #
104
+ # Called automatically between turns when used via Client.
105
+ # Call manually when reusing a standalone handler across turns.
106
+ #
107
+ # @return [void]
108
+ def reset!
109
+ @pending_tool_uses.clear
110
+ end
111
+
112
+ # Whether any handlers have been registered
113
+ # @return [Boolean]
114
+ def has_handlers?
115
+ @handlers.any? { |_, v| v.any? }
116
+ end
117
+
118
+ private
119
+
120
+ def handle_assistant(message)
121
+ text = message.text
122
+ emit(:text, text) unless text.empty?
123
+
124
+ thinking = message.thinking
125
+ emit(:thinking, thinking) unless thinking.empty?
126
+
127
+ message.content.each do |block|
128
+ case block
129
+ when ToolUseBlock, ServerToolUseBlock
130
+ @pending_tool_uses[block.id] = block
131
+ emit(:tool_use, block)
132
+ end
133
+ end
134
+ end
135
+
136
+ def handle_user(message)
137
+ return unless message.content.is_a?(Array)
138
+
139
+ message.content.each do |block|
140
+ case block
141
+ when ToolResultBlock, ServerToolResultBlock
142
+ tool_use = @pending_tool_uses.delete(block.tool_use_id)
143
+ emit(:tool_result, block, tool_use)
144
+ end
145
+ end
146
+ end
147
+
148
+ def emit(event, *args)
149
+ @handlers[event].each { |handler| handler.call(*args) }
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeAgent
6
+ # Reads a session's conversation transcript from its JSONL file.
7
+ #
8
+ # Given a session UUID, finds the session file on disk, parses all messages,
9
+ # reconstructs the main conversation thread (handling branches and forks),
10
+ # filters to user/assistant messages, and returns SessionMessage objects
11
+ # with optional pagination.
12
+ #
13
+ # Matches the TypeScript SDK's getSessionMessages() (v0.2.59).
14
+ #
15
+ # @example Basic usage
16
+ # messages = ClaudeAgent.get_session_messages("abc-123-def")
17
+ # messages.each { |m| puts "#{m.type}: #{m.message}" }
18
+ #
19
+ # @example With pagination
20
+ # messages = ClaudeAgent.get_session_messages("abc-123-def", limit: 10, offset: 5)
21
+ #
22
+ module GetSessionMessages
23
+ # Valid message types to include when parsing JSONL lines.
24
+ PARSEABLE_TYPES = %w[user assistant progress system attachment].freeze
25
+
26
+ class << self
27
+ # Read session messages with optional pagination.
28
+ #
29
+ # @param session_id [String] UUID of the session to read
30
+ # @param dir [String, nil] Project directory to find the session in
31
+ # @param limit [Integer, nil] Maximum number of messages to return
32
+ # @param offset [Integer, nil] Number of messages to skip from the start
33
+ # @return [Array<SessionMessage>] User/assistant messages, or empty array if not found
34
+ def call(session_id, dir: nil, limit: nil, offset: nil)
35
+ return [] unless SessionPaths::UUID_PATTERN.match?(session_id)
36
+
37
+ content = find_session_file(session_id, dir)
38
+ return [] unless content
39
+
40
+ raw_messages = parse_jsonl(content)
41
+ thread = reconstruct_thread(raw_messages)
42
+ result = thread.select { |msg| include_message?(msg) }.map { |msg| to_session_message(msg) }
43
+
44
+ off = offset || 0
45
+ if limit && limit > 0
46
+ result.slice(off, limit) || []
47
+ elsif off > 0
48
+ result.slice(off..) || []
49
+ else
50
+ result
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # Locate and read a session JSONL file from disk.
57
+ #
58
+ # @param session_id [String] Session UUID
59
+ # @param dir [String, nil] Optional project directory to scope search
60
+ # @return [String, nil] File contents or nil
61
+ def find_session_file(session_id, dir)
62
+ filename = "#{session_id}.jsonl"
63
+
64
+ if dir
65
+ resolved = SessionPaths.realpath(dir)
66
+ project_dir = SessionPaths.find_project_dir(resolved)
67
+ if project_dir
68
+ content = read_session_content(project_dir, filename)
69
+ return content if content
70
+ end
71
+
72
+ # Try worktrees
73
+ worktrees = SessionPaths.git_worktrees(resolved)
74
+ worktrees.each do |wt|
75
+ next if wt == resolved
76
+ wt_project_dir = SessionPaths.find_project_dir(wt)
77
+ if wt_project_dir
78
+ content = read_session_content(wt_project_dir, filename)
79
+ return content if content
80
+ end
81
+ end
82
+
83
+ nil
84
+ else
85
+ # Search all project directories
86
+ base = SessionPaths.projects_dir
87
+ return nil unless File.directory?(base)
88
+
89
+ Dir.entries(base).each do |entry|
90
+ next if entry.start_with?(".")
91
+ entry_path = File.join(base, entry)
92
+ next unless File.directory?(entry_path)
93
+ content = read_session_content(entry_path, filename)
94
+ return content if content
95
+ end
96
+
97
+ nil
98
+ end
99
+ end
100
+
101
+ # Read a session file's full contents.
102
+ #
103
+ # @param dir [String] Directory containing the file
104
+ # @param filename [String] JSONL filename
105
+ # @return [String, nil] File contents or nil
106
+ def read_session_content(dir, filename)
107
+ path = File.join(dir, filename)
108
+ File.read(path, encoding: "UTF-8")
109
+ rescue SystemCallError
110
+ nil
111
+ end
112
+
113
+ # Parse JSONL content into raw message hashes.
114
+ # Only includes messages with valid types and a uuid field.
115
+ #
116
+ # @param content [String] JSONL file contents
117
+ # @return [Array<Hash>] Parsed message hashes
118
+ def parse_jsonl(content)
119
+ messages = []
120
+ content.each_line do |line|
121
+ line = line.strip
122
+ next if line.empty?
123
+
124
+ begin
125
+ parsed = JSON.parse(line)
126
+ type = parsed["type"]
127
+ next unless PARSEABLE_TYPES.include?(type) && parsed["uuid"].is_a?(String)
128
+ messages << parsed
129
+ rescue JSON::ParserError
130
+ next
131
+ end
132
+ end
133
+ messages
134
+ end
135
+
136
+ # Reconstruct the main conversation thread from raw messages.
137
+ #
138
+ # Handles branches, forks, and sidechains by:
139
+ # 1. Building UUID -> message and UUID -> index maps
140
+ # 2. Finding leaf messages (messages with no children)
141
+ # 3. Walking up parentUuid chains to find user/assistant ancestors
142
+ # 4. Picking the best leaf (highest file position, prefer main thread)
143
+ # 5. Walking from best leaf to root to build chronological thread
144
+ #
145
+ # @param messages [Array<Hash>] All parsed messages from JSONL
146
+ # @return [Array<Hash>] Chronologically ordered main thread messages
147
+ def reconstruct_thread(messages)
148
+ # Build UUID -> message map
149
+ by_uuid = {}
150
+ messages.each { |msg| by_uuid[msg["uuid"]] = msg }
151
+
152
+ # Build UUID -> file index map (position in file)
153
+ index_map = {}
154
+ messages.each_with_index { |msg, i| index_map[msg["uuid"]] = i }
155
+
156
+ # Find parent UUIDs (messages that have children pointing to them)
157
+ parent_uuids = Set.new
158
+ messages.each do |msg|
159
+ parent_uuids.add(msg["parentUuid"]) if msg["parentUuid"]
160
+ end
161
+
162
+ # Find leaf messages (no children point to them)
163
+ leaves = messages.reject { |msg| parent_uuids.include?(msg["uuid"]) }
164
+
165
+ # For each leaf, walk up parentUuid chain to find nearest user/assistant ancestor
166
+ candidates = []
167
+ leaves.each do |leaf|
168
+ current = leaf
169
+ seen = Set.new
170
+ while current
171
+ break if seen.include?(current["uuid"])
172
+ seen.add(current["uuid"])
173
+ if current["type"] == "user" || current["type"] == "assistant"
174
+ candidates << current
175
+ break
176
+ end
177
+ current = current["parentUuid"] ? by_uuid[current["parentUuid"]] : nil
178
+ end
179
+ end
180
+
181
+ return [] if candidates.empty?
182
+
183
+ # Filter out sidechain/team/meta candidates
184
+ main_candidates = candidates.reject do |msg|
185
+ msg["isSidechain"] || msg["teamName"] || msg["isMeta"]
186
+ end
187
+
188
+ # Pick best: highest file position index
189
+ pick_best = ->(arr) {
190
+ arr.max_by { |msg| index_map[msg["uuid"]] || -1 }
191
+ }
192
+
193
+ best_leaf = main_candidates.empty? ? pick_best.call(candidates) : pick_best.call(main_candidates)
194
+
195
+ # Walk from best leaf back to root, building chronological thread
196
+ thread = []
197
+ visited = Set.new
198
+ current = best_leaf
199
+ while current
200
+ break if visited.include?(current["uuid"])
201
+ visited.add(current["uuid"])
202
+ thread.unshift(current) # prepend for chronological order
203
+ current = current["parentUuid"] ? by_uuid[current["parentUuid"]] : nil
204
+ end
205
+
206
+ thread
207
+ end
208
+
209
+ # Whether a message should be included in the final output.
210
+ #
211
+ # @param msg [Hash] Raw message hash
212
+ # @return [Boolean]
213
+ def include_message?(msg)
214
+ return false unless msg["type"] == "user" || msg["type"] == "assistant"
215
+ return false if msg["isMeta"]
216
+ return false if msg["isSidechain"]
217
+ return false if msg["teamName"]
218
+ true
219
+ end
220
+
221
+ # Transform a raw message hash into a SessionMessage.
222
+ #
223
+ # @param msg [Hash] Raw message hash from JSONL
224
+ # @return [SessionMessage]
225
+ def to_session_message(msg)
226
+ SessionMessage.new(
227
+ type: msg["type"],
228
+ uuid: msg["uuid"],
229
+ session_id: msg["sessionId"],
230
+ message: msg["message"],
231
+ parent_tool_use_id: nil
232
+ )
233
+ end
234
+ end
235
+ end
236
+ end