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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +603 -47
- data/SPEC.md +92 -28
- data/lib/claude_agent/client.rb +181 -7
- data/lib/claude_agent/content_blocks.rb +193 -5
- data/lib/claude_agent/control_protocol.rb +97 -11
- data/lib/claude_agent/conversation.rb +248 -0
- data/lib/claude_agent/cumulative_usage.rb +106 -0
- data/lib/claude_agent/event_handler.rb +152 -0
- data/lib/claude_agent/get_session_messages.rb +236 -0
- data/lib/claude_agent/hooks.rb +104 -253
- data/lib/claude_agent/list_sessions.rb +398 -0
- data/lib/claude_agent/mcp/server.rb +3 -3
- data/lib/claude_agent/mcp/tool.rb +4 -4
- data/lib/claude_agent/message_parser.rb +201 -185
- data/lib/claude_agent/messages.rb +86 -13
- data/lib/claude_agent/options.rb +5 -4
- data/lib/claude_agent/permission_queue.rb +87 -0
- data/lib/claude_agent/permission_request.rb +151 -0
- data/lib/claude_agent/permissions.rb +4 -2
- data/lib/claude_agent/query.rb +34 -0
- data/lib/claude_agent/session.rb +71 -3
- data/lib/claude_agent/session_message_relation.rb +59 -0
- data/lib/claude_agent/session_paths.rb +120 -0
- data/lib/claude_agent/tool_activity.rb +78 -0
- data/lib/claude_agent/turn_result.rb +239 -0
- data/lib/claude_agent/types.rb +45 -0
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +58 -2
- data/sig/claude_agent.rbs +336 -7
- metadata +12 -1
|
@@ -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
|