ralph.rb 1.2.4355354345 → 2.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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/gem-push.yml +2 -2
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +53 -0
  5. data/lib/ralph/cli.rb +67 -186
  6. data/lib/ralph/display.rb +105 -0
  7. data/lib/ralph/events.rb +117 -0
  8. data/lib/ralph/loop.rb +113 -170
  9. data/lib/ralph/metrics.rb +88 -0
  10. data/lib/ralph/opencode.rb +66 -0
  11. data/lib/ralph/version.rb +1 -1
  12. data/lib/ralph.rb +0 -3
  13. data/plans/00-complete-implementation.md +120 -0
  14. data/plans/01-cli-implementation.md +53 -0
  15. data/plans/02-loop-implementation.md +78 -0
  16. data/plans/03-agents-implementation.md +76 -0
  17. data/plans/04-metrics-implementation.md +98 -0
  18. data/plans/README.md +63 -0
  19. data/specs/README.md +4 -15
  20. data/specs/__templates__/API_TEMPLATE.md +0 -0
  21. data/specs/__templates__/AUTOMATION_ACTION_TEMPLATE.md +0 -0
  22. data/specs/__templates__/AUTOMATION_TRIGGER_TEMPLATE.md +0 -0
  23. data/specs/__templates__/CONTROLLER_TEMPLATE.md +32 -0
  24. data/specs/__templates__/INTEGRATION_TEMPLATE.md +0 -0
  25. data/specs/__templates__/MODEL_TEMPLATE.md +0 -0
  26. data/specs/agents.md +426 -120
  27. data/specs/cli.md +11 -218
  28. data/specs/lib/todo_item.rb +144 -0
  29. data/specs/log +15 -0
  30. data/specs/loop.md +42 -0
  31. data/specs/metrics.md +51 -0
  32. metadata +23 -39
  33. data/lib/ralph/agents/base.rb +0 -132
  34. data/lib/ralph/agents/claude_code.rb +0 -24
  35. data/lib/ralph/agents/codex.rb +0 -25
  36. data/lib/ralph/agents/open_code.rb +0 -30
  37. data/lib/ralph/agents.rb +0 -24
  38. data/lib/ralph/config.rb +0 -40
  39. data/lib/ralph/git/file_snapshot.rb +0 -60
  40. data/lib/ralph/helpers.rb +0 -76
  41. data/lib/ralph/iteration.rb +0 -220
  42. data/lib/ralph/output/active_loop_error.rb +0 -13
  43. data/lib/ralph/output/banner.rb +0 -29
  44. data/lib/ralph/output/completion_deferred.rb +0 -12
  45. data/lib/ralph/output/completion_detected.rb +0 -17
  46. data/lib/ralph/output/config_summary.rb +0 -31
  47. data/lib/ralph/output/context_consumed.rb +0 -11
  48. data/lib/ralph/output/iteration.rb +0 -45
  49. data/lib/ralph/output/max_iterations_reached.rb +0 -16
  50. data/lib/ralph/output/no_plugin_warning.rb +0 -14
  51. data/lib/ralph/output/nonzero_exit_warning.rb +0 -11
  52. data/lib/ralph/output/plugin_error.rb +0 -12
  53. data/lib/ralph/output/status.rb +0 -176
  54. data/lib/ralph/output/struggle_warning.rb +0 -18
  55. data/lib/ralph/output/task_completion.rb +0 -12
  56. data/lib/ralph/output/tasks_file_created.rb +0 -11
  57. data/lib/ralph/prompt_template.rb +0 -183
  58. data/lib/ralph/storage/context.rb +0 -58
  59. data/lib/ralph/storage/history.rb +0 -117
  60. data/lib/ralph/storage/state.rb +0 -178
  61. data/lib/ralph/storage/tasks.rb +0 -244
  62. data/lib/ralph/threads/heartbeat.rb +0 -44
  63. data/lib/ralph/threads/stream_reader.rb +0 -50
  64. data/original/bin/ralph.js +0 -13
  65. data/original/ralph.ts +0 -1706
  66. data/specs/iteration.md +0 -173
  67. data/specs/output.md +0 -104
  68. data/specs/storage/local-data-structure.md +0 -246
  69. data/specs/tasks.md +0 -295
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ac20f5a142fc020fb06842aa4b6ba550460362fa7e29170162fedc80eb520de
4
- data.tar.gz: 1b0b501cec69940980f1133f86e3ab4bebd09e2e8e5b4e44b36604e27a4cc700
3
+ metadata.gz: f3950370724ceca76366a10ef15914ab10abc209595d1a5c8ddc466c071ea032
4
+ data.tar.gz: 0f85bc6a1f1a00d98ee6940928ce3ba506f6591536e14e4eb0cd30c3e6e914db
5
5
  SHA512:
6
- metadata.gz: 2baaf26526ba2219005f4c15280539c9208fa15b233ddc6957cb9ed3052bf78579be8b406c61f9f573bd1e73b5589e1c14f903e5eb8761780068f62941f4fbbd
7
- data.tar.gz: 06bfc404d6c736d81c0422240dbbe9edb733a885bac815b91be0640744ecdb6905252bd846290684876aee51c6ce1918e785058eec8c901993edf350242b74f6
6
+ metadata.gz: 181d38c7c1e72fd04ac4b22118a09caac7f262d7037d4e1c42f4f3440bac0b6a3c3b08d48934b2083ec3972a241457a18770c3f6c0038d82bd3ae33afd790416
7
+ data.tar.gz: 1feee430c07868a4877f351c1dcef388f42b99cb1db33b1c7515e22aeda33dba7ac0ce1abd5568c1315ded580f674f48f394909db2267d26bc9a64092e8d62e9
@@ -2,9 +2,9 @@ name: Ruby Gem
2
2
 
3
3
  on:
4
4
  push:
5
- branches: ["ruby"]
5
+ branches: ["ruby2"]
6
6
  pull_request:
7
- branches: ["ruby"]
7
+ branches: ["ruby2"]
8
8
 
9
9
  jobs:
10
10
  build:
data/Gemfile CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gemspec
5
+ gemspec name: "ralph.rb"
6
6
 
7
7
  group :development do
8
8
  #gem "minitest", "~> 5.0"
data/Gemfile.lock ADDED
@@ -0,0 +1,53 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ ralph.rb (1.2.435535439)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ ast (2.4.3)
10
+ json (2.18.1)
11
+ language_server-protocol (3.17.0.5)
12
+ lint_roller (1.1.0)
13
+ minitest (5.27.0)
14
+ parallel (1.27.0)
15
+ parser (3.3.10.1)
16
+ ast (~> 2.4.1)
17
+ racc
18
+ prism (1.9.0)
19
+ racc (1.8.1)
20
+ rainbow (3.1.1)
21
+ rake (13.3.1)
22
+ regexp_parser (2.11.3)
23
+ rubocop (1.84.1)
24
+ json (~> 2.3)
25
+ language_server-protocol (~> 3.17.0.2)
26
+ lint_roller (~> 1.1.0)
27
+ parallel (~> 1.10)
28
+ parser (>= 3.3.0.2)
29
+ rainbow (>= 2.2.2, < 4.0)
30
+ regexp_parser (>= 2.9.3, < 3.0)
31
+ rubocop-ast (>= 1.49.0, < 2.0)
32
+ ruby-progressbar (~> 1.7)
33
+ unicode-display_width (>= 2.4.0, < 4.0)
34
+ rubocop-ast (1.49.0)
35
+ parser (>= 3.3.7.2)
36
+ prism (~> 1.7)
37
+ ruby-progressbar (1.13.0)
38
+ unicode-display_width (3.2.0)
39
+ unicode-emoji (~> 4.1)
40
+ unicode-emoji (4.2.0)
41
+
42
+ PLATFORMS
43
+ ruby
44
+ x86_64-linux
45
+
46
+ DEPENDENCIES
47
+ minitest (~> 5.0)
48
+ rake (~> 13.0)
49
+ ralph.rb!
50
+ rubocop (~> 1.21)
51
+
52
+ BUNDLED WITH
53
+ 2.7.2
data/lib/ralph/cli.rb CHANGED
@@ -1,222 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ralph
4
+ # Command-line interface for ralph. Parses arguments, reads stdin when piped,
5
+ # combines everything into a prompt, and launches the Loop.
6
+ #
7
+ # Usage:
8
+ # cat prompt.md | ralph "extra instructions" --model=opus-4.5 --max-iterations=10
4
9
  class CLI
5
- def initialize(argv = ARGV)
6
- @config = Config.new
7
-
8
- @parser = OptionParser.new do |o|
9
- o.banner = <<~BANNER
10
- Ralph Wiggum Loop - Iterative AI development with AI agents
11
-
12
- Usage:
13
- ralph "<prompt>" [options]
14
-
15
- Commands:
16
- --status Show current Ralph loop status and history
17
- --add-context TEXT Add context for the next iteration
18
- --clear-context Clear any pending context
19
- --list-tasks Display the current task list with indices
20
- --add-task "desc" Add a new task to the list
21
- --remove-task N Remove task at index N (including subtasks)
22
-
23
- Options:
24
- BANNER
25
-
26
- o.on("--agent AGENT", Agents.valid_agent_names, "AI agent: #{Agents.valid_agent_names.join(', ')} (default: opencode)") do |v|
27
- @config.chosen_agent = v
28
- end
29
-
30
- o.on("--min-iterations N", Integer, "Minimum iterations before completion (default: 1)") do |v|
31
- @config.min_iterations = v
32
- end
33
-
34
- o.on("--max-iterations N", Integer, "Maximum iterations before stopping (default: unlimited)") do |v|
35
- @config.max_iterations = v
36
- end
10
+ def initialize(argv)
11
+ @argv = argv.dup
12
+ @options = {
13
+ model: nil,
14
+ max_iterations: nil,
15
+ duration: nil,
16
+ max_context: nil,
17
+ completion: nil
18
+ }
19
+ end
37
20
 
38
- o.on("--completion-promise TEXT", "Phrase that signals completion (default: COMPLETE)") do |v|
39
- @config.completion_promise = v
21
+ def run
22
+ parse_options
23
+ build_prompt.then do |prompt|
24
+ if prompt.nil? || prompt.strip.empty?
25
+ $stderr.puts "Error: no prompt provided."
26
+ $stderr.puts "Usage: ralph \"your prompt\" [options]"
27
+ $stderr.puts " cat prompt.md | ralph [options]"
28
+ $stderr.puts
29
+ $stderr.puts parser.help
30
+ exit 1
31
+ else
32
+ @options[:prompt] = prompt
33
+ Ralph::Loop.new(@options).run
40
34
  end
35
+ end
36
+ end
41
37
 
42
- o.on("-t", "--tasks", "Enable Tasks Mode for structured task tracking") do
43
- @config.tasks_mode = true
44
- end
38
+ private
45
39
 
46
- o.on("--task-promise TEXT", "Phrase that signals task completion (default: READY_FOR_NEXT_TASK)") do |v|
47
- @config.task_promise = v
48
- end
40
+ def parse_options
41
+ parser.parse!(@argv)
42
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => error
43
+ $stderr.puts "Error: #{error.message}"
44
+ $stderr.puts parser.help
45
+ exit 1
46
+ end
49
47
 
50
- o.on("--model MODEL", "Model to use (agent-specific)") do |v|
51
- @config.model = v
52
- end
48
+ def parser
49
+ @parser ||= OptionParser.new do |option_parser|
50
+ option_parser.banner = "Usage: ralph [prompt] [options]"
51
+ option_parser.separator ""
52
+ option_parser.separator "Options:"
53
53
 
54
- o.on("--[no-]stream", "Stream agent output in real-time (default: on)") do |v|
55
- @config.stream_output = v
54
+ option_parser.on("--model=MODEL", "Model to use (e.g. opus-4.5)") do |value|
55
+ @options[:model] = value
56
56
  end
57
57
 
58
- o.on("--verbose-tools", "Print every tool line (disable compact summary)") do
59
- @config.verbose_tools = true
58
+ option_parser.on("--max-iterations=N", Integer, "Maximum number of iterations") do |value|
59
+ @options[:max_iterations] = value
60
60
  end
61
61
 
62
- o.on("--no-plugins", "Disable non-auth OpenCode plugins (opencode only)") do
63
- @config.disable_plugins = true
62
+ option_parser.on("--duration=SECONDS", Integer, "Maximum total duration in seconds") do |value|
63
+ @options[:duration] = value
64
64
  end
65
65
 
66
- o.on("--[no-]allow-all", "Auto-approve all tool permissions (default: on)") do |v|
67
- @config.allow_all_permissions = v
66
+ option_parser.on("--max-context=N", Integer, "Maximum context tokens before iteration restart") do |value|
67
+ @options[:max_context] = value
68
68
  end
69
69
 
70
- # Subcommands -- these set a command to dispatch after parsing
71
- o.on("-v", "--version", "Show version") do
72
- puts "ralph #{VERSION}"
73
- exit 0
70
+ option_parser.on("--completion=STRING", "Completion string the agent emits when done") do |value|
71
+ @options[:completion] = value
74
72
  end
75
73
 
76
- o.on("--status", "Show current loop status and history") do
77
- Output::Status.call(options: @config.to_h)
74
+ option_parser.on("-h", "--help", "Show this help message") do
75
+ puts option_parser
78
76
  exit 0
79
77
  end
80
78
 
81
- o.on("--add-context TEXT", "Add context for the next iteration") do |context_text|
82
-
83
- Storage::Context.new.append(
84
- "\n## Context added at #{Time.now.utc.iso8601}\n#{context_text}\n"
85
- )
86
-
87
- puts "✅ Context added for next iteration"
88
- puts " File: #{Storage::Context.new.path}"
89
-
90
- Storage::State.load.then do |state|
91
- if state&.active
92
- puts " Will be picked up in iteration #{state.iteration + 1}"
93
- else
94
- puts " Will be used when loop starts"
95
- end
96
- end
97
-
79
+ option_parser.on("-v", "--version", "Show version") do
80
+ puts "ralph #{Ralph::VERSION}"
98
81
  exit 0
99
82
  end
100
-
101
- o.on("--clear-context", "Clear any pending context") do
102
- Storage::Context.new.then do |context|
103
- if context.present?
104
- context.clear
105
- puts "✅ Context cleared"
106
- else
107
- puts "ℹ️ No pending context to clear"
108
- end
109
- end
110
-
111
- exit 0
112
- end
113
-
114
- o.on("--list-tasks", "Display the current task list") do
115
- begin
116
- Storage::Tasks.new.load_tasks.then do |tasks|
117
- if tasks
118
- tasks.display_with_indices
119
- else
120
- puts "No tasks file found. Use --add-task to create your first task."
121
- end
122
- end
123
- rescue StandardError => e
124
- $stderr.puts "Error reading tasks file: #{e}"
125
- exit 1
126
- end
127
-
128
- exit 0
129
- end
130
-
131
- o.on("--add-task DESC", "Add a new task to the list") do |description|
132
- begin
133
- Storage::Tasks.new.add_task(description)
134
- puts "✅ Task added: \"#{description}\""
135
- rescue StandardError => e
136
- $stderr.puts "Error adding task: #{e}"
137
- exit 1
138
- end
139
-
140
- exit 0
141
- end
142
-
143
- o.on("--remove-task N", Integer, "Remove task at index N") do |task_index|
144
- begin
145
- Storage::Tasks.new.remove_task(task_index)
146
- puts "✅ Removed task #{task_index} and its subtasks"
147
- rescue IndexError => e
148
- $stderr.puts "Error: #{e.message}"
149
- exit 1
150
- rescue RuntimeError => e
151
- $stderr.puts "Error: #{e.message}"
152
- exit 1
153
- rescue StandardError => e
154
- $stderr.puts "Error removing task: #{e}"
155
- exit 1
156
- end
157
-
158
- exit 0
159
- end
160
-
161
- o.separator ""
162
- o.separator "Examples:"
163
- o.separator ' ralph "Build a REST API for todos"'
164
- o.separator ' ralph "Fix the auth bug" --max-iterations 10'
165
- o.separator ' ralph "Add tests" --completion-promise "ALL TESTS PASS" --model openai/gpt-5.1'
166
- o.separator ' ralph --status'
167
- o.separator ' ralph --add-context "Focus on the auth module first"'
168
- o.separator ""
169
- o.separator "How it works:"
170
- o.separator " 1. Sends your prompt to the selected AI agent"
171
- o.separator " 2. AI agent works on the task"
172
- o.separator " 3. Checks output for completion promise"
173
- o.separator " 4. If not complete, repeats with same prompt"
174
- o.separator " 5. AI sees its previous work in files"
175
- o.separator " 6. Continues until promise detected or max iterations"
176
- o.separator ""
177
- o.separator "To stop manually: Ctrl+C"
178
- o.separator "Learn more: https://ghuntley.com/ralph/"
179
83
  end
180
-
181
84
  end
182
85
 
183
- def run(argv = ARGV.dup)
184
- @user_prompt = @parser.parse(argv).then do |remaining_args|
185
- if $stdin.tty?
186
- remaining_args.join(" ").strip
187
- else
188
- [$stdin.read, remaining_args.join(" ")].join("\n").strip
189
- end
190
- end
86
+ def build_prompt
87
+ parts = []
191
88
 
192
- if @user_prompt.empty?
193
- abort "
194
- Error: No prompt provided
195
- Usage: ralph 'Your task description' [options]
196
- Run 'ralph --help' for more information
197
- "
89
+ # Read from stdin if data is being piped in
90
+ unless $stdin.tty?
91
+ stdin_content = $stdin.read
92
+ parts << stdin_content if stdin_content && !stdin_content.strip.empty?
198
93
  end
199
94
 
200
- tasks = Storage::Tasks.new
201
- context = Storage::Context.new
95
+ # Remaining positional arguments are the inline prompt
96
+ parts << @argv.join(" ") if @argv.any?
202
97
 
203
- PromptTemplate.inject(@user_prompt, context: context, tasks: tasks).then do |prompt|
204
- @config.prompt = prompt
205
-
206
- if @config.max_iterations > 0 && @config.min_iterations > @config.max_iterations
207
- abort "Error: --min-iterations (#{@config.min_iterations}) cannot be greater than --max-iterations (#{@config.max_iterations})"
208
- end
209
-
210
- state = Storage::State.from_config(@config, prompt: prompt)
211
- history = Storage::History.new
212
-
213
- Ralph::Loop.new(@config, state, history, context, tasks).run
98
+ if parts.any?
99
+ parts.join("\n\n")
214
100
  end
215
-
216
- rescue StandardError => e
217
- $stderr.puts "Fatal error: #{e}"
218
- Storage::State.clear
219
- exit 1
220
101
  end
221
102
  end
222
103
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ # Handles all terminal output for the loop -- iteration status, metrics,
5
+ # agent text, and summary information.
6
+ class Display
7
+ SEPARATOR = "-" * 60
8
+
9
+ def initialize(loop_engine)
10
+ @loop_engine = loop_engine
11
+ end
12
+
13
+ def show_start(prompt)
14
+ puts SEPARATOR
15
+ puts "ralph -- autonomous agentic loop"
16
+ puts SEPARATOR
17
+ puts "Prompt: #{prompt[0, 200]}#{"..." if prompt.length > 200}"
18
+ puts SEPARATOR
19
+ end
20
+
21
+ def show_iteration_start
22
+ puts
23
+ puts "#{SEPARATOR}"
24
+ puts "Iteration #{@loop_engine.iteration_number} | " \
25
+ "elapsed: #{format_duration(@loop_engine.elapsed_seconds)} | " \
26
+ "context: #{format_tokens(@loop_engine.metrics.current_context)} | " \
27
+ "tokens: #{format_tokens(@loop_engine.metrics.tokens_consumed)}"
28
+ puts SEPARATOR
29
+ end
30
+
31
+ def show_event(event)
32
+ if event.is_a?(Events::Text) && event.text
33
+ print event.text
34
+ $stdout.flush
35
+ end
36
+
37
+ if event.is_a?(Events::ToolUse)
38
+ puts
39
+ puts " [tool] #{event.tool} (#{event.status})"
40
+ end
41
+
42
+ if event.is_a?(Events::StepFinish)
43
+ puts
44
+ puts " [step] context=#{format_tokens(event.context_size)} " \
45
+ "output=#{format_tokens(event.output_tokens)} " \
46
+ "cache_r=#{format_tokens(event.cache_read)} " \
47
+ "cache_w=#{format_tokens(event.cache_write)}"
48
+ end
49
+ end
50
+
51
+ def show_iteration_end
52
+ puts
53
+ puts " Iteration #{@loop_engine.iteration_number} complete | " \
54
+ "steps: #{@loop_engine.metrics.step_count} | " \
55
+ "iteration tokens: #{format_tokens(@loop_engine.metrics.iteration_tokens)}"
56
+ end
57
+
58
+ def show_iteration_cancelled(reason)
59
+ puts
60
+ puts " ** Iteration cancelled: #{reason}"
61
+ end
62
+
63
+ def show_termination(reason)
64
+ puts
65
+ puts " ** Loop terminated: #{reason}"
66
+ end
67
+
68
+ def show_summary
69
+ metrics = @loop_engine.metrics
70
+ puts
71
+ puts SEPARATOR
72
+ puts "SUMMARY"
73
+ puts SEPARATOR
74
+ puts " Status: #{@loop_engine.completed ? "COMPLETED" : "TERMINATED"}"
75
+ puts " Iterations: #{@loop_engine.iteration_number}"
76
+ puts " Duration: #{format_duration(@loop_engine.elapsed_seconds)}"
77
+ puts " Steps: #{metrics.step_count}"
78
+ puts " Tokens: #{format_tokens(metrics.tokens_consumed)}"
79
+ puts " Context: #{format_tokens(metrics.current_context)}"
80
+ puts SEPARATOR
81
+ end
82
+
83
+ private
84
+
85
+ def format_duration(seconds)
86
+ minutes = (seconds / 60).to_i
87
+ remaining_seconds = (seconds % 60).to_i
88
+ if minutes > 0
89
+ "#{minutes}m #{remaining_seconds}s"
90
+ else
91
+ "#{remaining_seconds}s"
92
+ end
93
+ end
94
+
95
+ def format_tokens(count)
96
+ if count >= 1_000_000
97
+ format("%.1fM", count / 1_000_000.0)
98
+ elsif count >= 1_000
99
+ format("%.1fk", count / 1_000.0)
100
+ else
101
+ count.to_s
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Events
5
+ # Base event wrapping the common fields from opencode JSON stream lines
6
+ class Base
7
+ attr_reader :type, :timestamp, :session_id, :part
8
+
9
+ def initialize(data)
10
+ @type = data["type"]
11
+ @timestamp = data["timestamp"]
12
+ @session_id = data["sessionID"]
13
+ @part = data["part"] || {}
14
+ end
15
+ end
16
+
17
+ # Emitted when a new LLM step begins
18
+ class StepStart < Base
19
+ def snapshot
20
+ part["snapshot"]
21
+ end
22
+ end
23
+
24
+ # Emitted when the model streams text content
25
+ class Text < Base
26
+ def text
27
+ part["text"]
28
+ end
29
+ end
30
+
31
+ # Emitted when the model invokes a tool
32
+ class ToolUse < Base
33
+ def tool
34
+ part["tool"]
35
+ end
36
+
37
+ def call_id
38
+ part["callID"]
39
+ end
40
+
41
+ def state
42
+ part["state"] || {}
43
+ end
44
+
45
+ def status
46
+ state["status"]
47
+ end
48
+
49
+ def input
50
+ state["input"]
51
+ end
52
+
53
+ def output
54
+ state["output"]
55
+ end
56
+ end
57
+
58
+ # Emitted when a step completes -- carries the token usage data
59
+ class StepFinish < Base
60
+ def reason
61
+ part["reason"]
62
+ end
63
+
64
+ def tokens
65
+ part["tokens"] || {}
66
+ end
67
+
68
+ def input_tokens
69
+ tokens["input"] || 0
70
+ end
71
+
72
+ def output_tokens
73
+ tokens["output"] || 0
74
+ end
75
+
76
+ def reasoning_tokens
77
+ tokens["reasoning"] || 0
78
+ end
79
+
80
+ def cache
81
+ tokens["cache"] || {}
82
+ end
83
+
84
+ def cache_read
85
+ cache["read"] || 0
86
+ end
87
+
88
+ def cache_write
89
+ cache["write"] || 0
90
+ end
91
+
92
+ # Total context size for this step: input + cache.read + cache.write
93
+ def context_size
94
+ input_tokens + cache_read + cache_write
95
+ end
96
+ end
97
+
98
+ EVENT_TYPES = {
99
+ "step_start" => StepStart,
100
+ "text" => Text,
101
+ "tool_use" => ToolUse,
102
+ "step_finish" => StepFinish
103
+ }.freeze
104
+
105
+ # Parse a single JSON line into the appropriate event object.
106
+ # Returns nil for unknown event types or malformed JSON.
107
+ def self.parse(line)
108
+ data = JSON.parse(line)
109
+ event_class = EVENT_TYPES[data["type"]]
110
+ if event_class
111
+ event_class.new(data)
112
+ end
113
+ rescue JSON::ParserError
114
+ nil
115
+ end
116
+ end
117
+ end