ralph.rb 1.2.4355354345 → 2.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 (68) 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/README.md +3 -8
  6. data/lib/ralph/cli.rb +101 -183
  7. data/lib/ralph/display.rb +110 -0
  8. data/lib/ralph/events.rb +117 -0
  9. data/lib/ralph/iteration.rb +70 -190
  10. data/lib/ralph/loop.rb +115 -174
  11. data/lib/ralph/metrics.rb +88 -0
  12. data/lib/ralph/opencode.rb +66 -0
  13. data/lib/ralph/prompt/build.rb +60 -0
  14. data/lib/ralph/prompt/plan.rb +49 -0
  15. data/lib/ralph/version.rb +1 -1
  16. data/lib/ralph.rb +0 -3
  17. data/plans/00-complete-implementation.md +128 -0
  18. data/plans/01-cli-implementation.md +65 -0
  19. data/plans/02-loop-implementation.md +78 -0
  20. data/plans/03-agents-implementation.md +76 -0
  21. data/plans/04-metrics-implementation.md +98 -0
  22. data/plans/05-prompts-implementation.md +66 -0
  23. data/plans/README.md +68 -0
  24. data/ralph.jpg +0 -0
  25. data/reading/ralph-playbook.md +2119 -0
  26. data/specs/README.md +5 -15
  27. data/specs/agents.md +426 -120
  28. data/specs/cli.md +31 -210
  29. data/specs/loop.md +76 -0
  30. data/specs/metrics.md +51 -0
  31. data/specs/prompts.md +137 -0
  32. metadata +21 -38
  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/output/active_loop_error.rb +0 -13
  42. data/lib/ralph/output/banner.rb +0 -29
  43. data/lib/ralph/output/completion_deferred.rb +0 -12
  44. data/lib/ralph/output/completion_detected.rb +0 -17
  45. data/lib/ralph/output/config_summary.rb +0 -31
  46. data/lib/ralph/output/context_consumed.rb +0 -11
  47. data/lib/ralph/output/iteration.rb +0 -45
  48. data/lib/ralph/output/max_iterations_reached.rb +0 -16
  49. data/lib/ralph/output/no_plugin_warning.rb +0 -14
  50. data/lib/ralph/output/nonzero_exit_warning.rb +0 -11
  51. data/lib/ralph/output/plugin_error.rb +0 -12
  52. data/lib/ralph/output/status.rb +0 -176
  53. data/lib/ralph/output/struggle_warning.rb +0 -18
  54. data/lib/ralph/output/task_completion.rb +0 -12
  55. data/lib/ralph/output/tasks_file_created.rb +0 -11
  56. data/lib/ralph/prompt_template.rb +0 -183
  57. data/lib/ralph/storage/context.rb +0 -58
  58. data/lib/ralph/storage/history.rb +0 -117
  59. data/lib/ralph/storage/state.rb +0 -178
  60. data/lib/ralph/storage/tasks.rb +0 -244
  61. data/lib/ralph/threads/heartbeat.rb +0 -44
  62. data/lib/ralph/threads/stream_reader.rb +0 -50
  63. data/original/bin/ralph.js +0 -13
  64. data/original/ralph.ts +0 -1706
  65. data/specs/iteration.md +0 -173
  66. data/specs/output.md +0 -104
  67. data/specs/storage/local-data-structure.md +0 -246
  68. 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: 5554c13454fc43d04f41b7f969653cb7d51243eb40df245aefad31d82b38ab3a
4
+ data.tar.gz: 97909bf34b37d86ed11815c332f6ef6ba747c82c5ae60ded0e4b184a1d4a8120
5
5
  SHA512:
6
- metadata.gz: 2baaf26526ba2219005f4c15280539c9208fa15b233ddc6957cb9ed3052bf78579be8b406c61f9f573bd1e73b5589e1c14f903e5eb8761780068f62941f4fbbd
7
- data.tar.gz: 06bfc404d6c736d81c0422240dbbe9edb733a885bac815b91be0640744ecdb6905252bd846290684876aee51c6ce1918e785058eec8c901993edf350242b74f6
6
+ metadata.gz: 6cab0f8e38f76a14251de705f0404ed443b9d597ddd93c3ba51a4a46f8ba8db5849d5819570034c95d70e9fbe95a4a4bf576df35564d0405c393eaaf7a1765e5
7
+ data.tar.gz: 2c9e36d50e6f304a018cac0deaba455421f67b5199e36b756dcffe9adb35fb443f9eaf09d8279231c3c85df10adbff1b88407a4d826cb2f3a7acb0a594c339ff
@@ -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 (2.0.0)
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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  <p align="center">
2
- <h1 align="center">Open Ralph Wiggum</h1>
3
- <h3 align="center">Autonomous Agentic Loop for Claude Code, Codex & OpenCode</h3>
2
+ <h1 align="center">Ralph.rb</h1>
3
+ <h3 align="center">Autonomous Agentic Loop for OpenCode</h3>
4
4
  </p>
5
5
 
6
6
  <p align="center">
7
- <img src="screenshot.webp" alt="Open Ralph Wiggum - Iterative AI coding loop for Claude Code and Codex" />
7
+ <img src="ralph.jpg" alt="Ralph Wiggum - Iterative AI coding loop for Opencode" />
8
8
  </p>
9
9
 
10
10
  <p align="center">
@@ -13,11 +13,6 @@
13
13
  <em>Based on the <a href="https://ghuntley.com/ralph/">Ralph Wiggum technique</a> by Geoffrey Huntley</em>
14
14
  </p>
15
15
 
16
- <p align="center">
17
- <a href="https://github.com/Th0rgal/ralph-wiggum/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
18
- <a href="https://github.com/Th0rgal/ralph-wiggum"><img src="https://img.shields.io/badge/built%20with-Bun%20%2B%20TypeScript-f472b6.svg" alt="Built with Bun + TypeScript"></a>
19
- <a href="https://github.com/Th0rgal/ralph-wiggum/releases"><img src="https://img.shields.io/github/v/release/Th0rgal/ralph-wiggum?include_prereleases" alt="Release"></a>
20
- </p>
21
16
 
22
17
  <p align="center">
23
18
  <a href="#supported-agents">Supported Agents</a> •
data/lib/ralph/cli.rb CHANGED
@@ -1,222 +1,140 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ralph
4
+ # Command-line interface for ralph. Parses subcommands (build/plan),
5
+ # arguments, reads stdin when piped, and launches the Loop.
6
+ #
7
+ # Usage:
8
+ # ralph build "focus on auth" --model=opus-4.5 --max-iterations=10
9
+ # ralph plan "user authentication system"
10
+ # ralph --max-iterations=10 # equivalent to: ralph build --max-iterations=10
11
+ # cat prompt.md | ralph build --model=opus-4.5
4
12
  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
37
-
38
- o.on("--completion-promise TEXT", "Phrase that signals completion (default: COMPLETE)") do |v|
39
- @config.completion_promise = v
40
- end
41
-
42
- o.on("-t", "--tasks", "Enable Tasks Mode for structured task tracking") do
43
- @config.tasks_mode = true
44
- end
45
-
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
49
-
50
- o.on("--model MODEL", "Model to use (agent-specific)") do |v|
51
- @config.model = v
52
- end
53
-
54
- o.on("--[no-]stream", "Stream agent output in real-time (default: on)") do |v|
55
- @config.stream_output = v
56
- end
13
+ SUBCOMMANDS = %w[build plan].freeze
14
+
15
+ attr_reader :subcommand
16
+
17
+ def initialize(argv)
18
+ @argv = argv.dup
19
+ @subcommand = extract_subcommand
20
+ @options = {
21
+ model: nil,
22
+ max_iterations: nil,
23
+ duration: nil,
24
+ max_context: nil,
25
+ completion: nil
26
+ }
27
+ end
57
28
 
58
- o.on("--verbose-tools", "Print every tool line (disable compact summary)") do
59
- @config.verbose_tools = true
60
- end
29
+ def run
30
+ parse_options
31
+ prompt_object = build_prompt_object
32
+ @options[:prompt] = prompt_object
61
33
 
62
- o.on("--no-plugins", "Disable non-auth OpenCode plugins (opencode only)") do
63
- @config.disable_plugins = true
64
- end
34
+ Ralph::Loop.new(@options).run
65
35
 
66
- o.on("--[no-]allow-all", "Auto-approve all tool permissions (default: on)") do |v|
67
- @config.allow_all_permissions = v
68
- end
36
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => error
37
+ $stderr.puts "Error: #{error.message}"
38
+ $stderr.puts parser.help
39
+ exit 1
40
+ end
69
41
 
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
74
- end
42
+ private
75
43
 
76
- o.on("--status", "Show current loop status and history") do
77
- Output::Status.call(options: @config.to_h)
78
- exit 0
44
+ def extract_subcommand
45
+ if @argv.first && SUBCOMMANDS.include?(@argv.first)
46
+ @argv.shift
47
+ else
48
+ "build"
79
49
  end
50
+ end
80
51
 
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
52
+ def parse_options
53
+ parser.parse!(@argv)
54
+ end
55
+
56
+ def parser
57
+ @parser ||= OptionParser.new do |option_parser|
58
+ option_parser.banner = "Usage: ralph [build|plan] [text] [options]"
59
+ option_parser.separator ""
60
+ option_parser.separator "Subcommands:"
61
+ option_parser.separator " build Implement tasks from the plan (default)"
62
+ option_parser.separator " plan Gap analysis and plan generation"
63
+ option_parser.separator ""
64
+ option_parser.separator "Options:"
65
+
66
+ option_parser.on("--model=MODEL", "Model to use (e.g. opus-4.5)") do |value|
67
+ @options[:model] = value
96
68
  end
97
69
 
98
- exit 0
99
- 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
70
+ option_parser.on("--max-iterations=N", Integer, "Maximum number of iterations") do |value|
71
+ @options[:max_iterations] = value
109
72
  end
110
73
 
111
- exit 0
112
- end
74
+ option_parser.on("--duration=SECONDS", Integer, "Maximum total duration in seconds") do |value|
75
+ @options[:duration] = value
76
+ end
113
77
 
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
78
+ option_parser.on("--max-context=N", Integer, "Maximum context tokens before iteration restart") do |value|
79
+ @options[:max_context] = value
126
80
  end
127
81
 
128
- exit 0
129
- end
82
+ option_parser.on("--completion=STRING", "Completion string the agent emits when done") do |value|
83
+ @options[:completion] = value
84
+ end
130
85
 
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
86
+ option_parser.on("-h", "--help", "Show this help message") do
87
+ puts option_parser
88
+ exit 0
138
89
  end
139
90
 
140
- exit 0
91
+ option_parser.on("-v", "--version", "Show version") do
92
+ puts "ralph #{Ralph::VERSION}"
93
+ exit 0
94
+ end
141
95
  end
96
+ end
142
97
 
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
98
+ def read_user_text
99
+ parts = []
157
100
 
158
- exit 0
101
+ # Read from stdin if data is being piped in
102
+ unless $stdin.tty?
103
+ stdin_content = $stdin.read
104
+ parts << stdin_content if stdin_content && !stdin_content.strip.empty?
159
105
  end
160
106
 
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/"
107
+ # Remaining positional arguments are the inline text
108
+ parts << @argv.join(" ") if @argv.any?
109
+
110
+ parts.join("\n\n") if parts.any?
179
111
  end
180
112
 
181
- end
113
+ def build_prompt_object
114
+ user_text = read_user_text
115
+ completion = @options[:completion]
182
116
 
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
117
+ if @subcommand == "plan"
118
+ build_plan_prompt(user_text, completion)
187
119
  else
188
- [$stdin.read, remaining_args.join(" ")].join("\n").strip
120
+ build_build_prompt(user_text, completion)
189
121
  end
190
122
  end
191
123
 
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
- "
124
+ def build_plan_prompt(user_text, completion)
125
+ Hash.new.then do |plan_options|
126
+ plan_options[:goal] = user_text if user_text
127
+ plan_options[:all_done] = completion if completion
128
+ Prompt::Plan.new(**plan_options)
129
+ end
198
130
  end
199
131
 
200
- tasks = Storage::Tasks.new
201
- context = Storage::Context.new
202
-
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})"
132
+ def build_build_prompt(user_text, completion)
133
+ Hash.new.then do |build_options|
134
+ build_options[:context] = user_text if user_text
135
+ build_options[:all_done] = completion if completion
136
+ Prompt::Build.new(**build_options)
208
137
  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
214
138
  end
215
-
216
- rescue StandardError => e
217
- $stderr.puts "Fatal error: #{e}"
218
- Storage::State.clear
219
- exit 1
220
- end
221
139
  end
222
140
  end
@@ -0,0 +1,110 @@
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_iteration_error(message)
64
+ puts
65
+ puts " ** Iteration error: #{message}"
66
+ end
67
+
68
+ def show_termination(reason)
69
+ puts
70
+ puts " ** Loop terminated: #{reason}"
71
+ end
72
+
73
+ def show_summary
74
+ metrics = @loop_engine.metrics
75
+ puts
76
+ puts SEPARATOR
77
+ puts "SUMMARY"
78
+ puts SEPARATOR
79
+ puts " Status: #{@loop_engine.completed ? "COMPLETED" : "TERMINATED"}"
80
+ puts " Iterations: #{@loop_engine.iteration_number}"
81
+ puts " Duration: #{format_duration(@loop_engine.elapsed_seconds)}"
82
+ puts " Steps: #{metrics.step_count}"
83
+ puts " Tokens: #{format_tokens(metrics.tokens_consumed)}"
84
+ puts " Context: #{format_tokens(metrics.current_context)}"
85
+ puts SEPARATOR
86
+ end
87
+
88
+ private
89
+
90
+ def format_duration(seconds)
91
+ minutes = (seconds / 60).to_i
92
+ remaining_seconds = (seconds % 60).to_i
93
+ if minutes > 0
94
+ "#{minutes}m #{remaining_seconds}s"
95
+ else
96
+ "#{remaining_seconds}s"
97
+ end
98
+ end
99
+
100
+ def format_tokens(count)
101
+ if count >= 1_000_000
102
+ format("%.1fM", count / 1_000_000.0)
103
+ elsif count >= 1_000
104
+ format("%.1fk", count / 1_000.0)
105
+ else
106
+ count.to_s
107
+ end
108
+ end
109
+ end
110
+ 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