rubyrlm 0.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE +21 -0
  4. data/README.md +300 -0
  5. data/bin/rubyrlm +168 -0
  6. data/lib/rubyrlm/backends/base.rb +9 -0
  7. data/lib/rubyrlm/backends/gemini_rest.rb +317 -0
  8. data/lib/rubyrlm/client.rb +643 -0
  9. data/lib/rubyrlm/completion.rb +71 -0
  10. data/lib/rubyrlm/errors.rb +9 -0
  11. data/lib/rubyrlm/logger/jsonl_logger.rb +27 -0
  12. data/lib/rubyrlm/pricing.rb +88 -0
  13. data/lib/rubyrlm/prompts/system_prompt.rb +108 -0
  14. data/lib/rubyrlm/protocol/action_parser.rb +84 -0
  15. data/lib/rubyrlm/repl/code_validator.rb +113 -0
  16. data/lib/rubyrlm/repl/docker_repl/container_manager.rb +158 -0
  17. data/lib/rubyrlm/repl/docker_repl/host_rpc_server.rb +164 -0
  18. data/lib/rubyrlm/repl/docker_repl/protocol.rb +26 -0
  19. data/lib/rubyrlm/repl/docker_repl.rb +190 -0
  20. data/lib/rubyrlm/repl/execution_result.rb +41 -0
  21. data/lib/rubyrlm/repl/local_repl.rb +476 -0
  22. data/lib/rubyrlm/sub_call_cache.rb +47 -0
  23. data/lib/rubyrlm/version.rb +3 -0
  24. data/lib/rubyrlm/web/app.rb +41 -0
  25. data/lib/rubyrlm/web/public/css/components.css +649 -0
  26. data/lib/rubyrlm/web/public/css/design-system.css +1396 -0
  27. data/lib/rubyrlm/web/public/js/app.js +1016 -0
  28. data/lib/rubyrlm/web/public/js/components/charts.js +68 -0
  29. data/lib/rubyrlm/web/public/js/components/context-inspector.js +94 -0
  30. data/lib/rubyrlm/web/public/js/components/exec-chain.js +105 -0
  31. data/lib/rubyrlm/web/public/js/components/kpi-dashboard.js +187 -0
  32. data/lib/rubyrlm/web/public/js/components/query-panel.js +335 -0
  33. data/lib/rubyrlm/web/public/js/components/recursion-tree.js +83 -0
  34. data/lib/rubyrlm/web/public/js/components/session-list.js +160 -0
  35. data/lib/rubyrlm/web/public/js/components/step-navigator.js +129 -0
  36. data/lib/rubyrlm/web/public/js/components/timeline.js +281 -0
  37. data/lib/rubyrlm/web/public/js/lib/animation.js +46 -0
  38. data/lib/rubyrlm/web/public/js/lib/chart-renderer.js +116 -0
  39. data/lib/rubyrlm/web/public/js/lib/diagram-renderer.js +233 -0
  40. data/lib/rubyrlm/web/public/js/lib/sse-client.js +94 -0
  41. data/lib/rubyrlm/web/public/js/lib/theme-manager.js +39 -0
  42. data/lib/rubyrlm/web/public/js/utils.js +57 -0
  43. data/lib/rubyrlm/web/routes/api.rb +129 -0
  44. data/lib/rubyrlm/web/routes/pages.rb +365 -0
  45. data/lib/rubyrlm/web/routes/sse.rb +95 -0
  46. data/lib/rubyrlm/web/services/event_broadcaster.rb +36 -0
  47. data/lib/rubyrlm/web/services/export_service.rb +903 -0
  48. data/lib/rubyrlm/web/services/query_service.rb +221 -0
  49. data/lib/rubyrlm/web/services/session_loader.rb +356 -0
  50. data/lib/rubyrlm/web/services/streaming_logger.rb +22 -0
  51. data/lib/rubyrlm.rb +18 -0
  52. metadata +208 -0
@@ -0,0 +1,476 @@
1
+ require_relative "code_validator"
2
+ require_relative "execution_result"
3
+ require "stringio"
4
+ require "timeout"
5
+ require "json"
6
+ require "net/http"
7
+ require "open3"
8
+ require "pathname"
9
+ require "uri"
10
+
11
+ module RubyRLM
12
+ module Repl
13
+ class LocalRepl
14
+ DEFAULT_TIMEOUT_SECONDS = 60
15
+ class IndifferentHash < Hash
16
+ def initialize(source = {})
17
+ super()
18
+ merge!(source)
19
+ end
20
+
21
+ def [](key)
22
+ super(normalize_key(key))
23
+ end
24
+
25
+ def []=(key, value)
26
+ super(normalize_key(key), convert_value(value))
27
+ end
28
+
29
+ def fetch(key, *args, &block)
30
+ super(normalize_key(key), *args, &block)
31
+ end
32
+
33
+ def key?(key)
34
+ super(normalize_key(key))
35
+ end
36
+ alias has_key? key?
37
+
38
+ def dig(key, *rest)
39
+ value = self[key]
40
+ return value if rest.empty? || value.nil?
41
+
42
+ if value.respond_to?(:dig)
43
+ value.dig(*rest)
44
+ else
45
+ rest.reduce(value) { |acc, next_key| acc.respond_to?(:[]) ? acc[next_key] : nil }
46
+ end
47
+ end
48
+
49
+ def merge!(other_hash)
50
+ other_hash.each { |key, value| self[key] = value }
51
+ self
52
+ end
53
+
54
+ private
55
+
56
+ def normalize_key(key)
57
+ return key.to_sym if key.is_a?(String) || key.is_a?(Symbol)
58
+
59
+ key
60
+ end
61
+
62
+ def convert_value(value)
63
+ case value
64
+ when Hash
65
+ self.class.new(value)
66
+ when Array
67
+ value.map { |item| convert_value(item) }
68
+ else
69
+ value
70
+ end
71
+ end
72
+ end
73
+
74
+ attr_reader :modifications
75
+
76
+ def initialize(context:, llm_query_proc:, timeout_seconds: DEFAULT_TIMEOUT_SECONDS)
77
+ @context = deep_indifferentize(context)
78
+ @llm_query_proc = llm_query_proc
79
+ @timeout_seconds = timeout_seconds
80
+ @workspace_root = Pathname.new(Dir.pwd).expand_path
81
+ @modifications = []
82
+ @host = Object.new
83
+ @host.instance_variable_set(:@context, @context)
84
+ install_helpers
85
+ end
86
+
87
+ def execute(code)
88
+ # AST validation: catch syntax errors and dangerous calls before eval
89
+ begin
90
+ warnings = CodeValidator.validate!(code)
91
+ rescue CodeValidationError => e
92
+ return ExecutionResult.new(
93
+ ok: false,
94
+ stdout: "",
95
+ stderr: "",
96
+ error_class: "CodeValidationError",
97
+ error_message: e.message,
98
+ backtrace_excerpt: []
99
+ )
100
+ end
101
+
102
+ stdout_buffer = StringIO.new
103
+ stderr_buffer = StringIO.new
104
+ prior_stdout = $stdout
105
+ prior_stderr = $stderr
106
+ value = nil
107
+
108
+ begin
109
+ $stdout = stdout_buffer
110
+ $stderr = stderr_buffer
111
+ Timeout.timeout(@timeout_seconds) do
112
+ wrapped_code = "context = self.context\n#{code}"
113
+ value = @host.instance_eval(wrapped_code)
114
+ end
115
+ ExecutionResult.new(
116
+ ok: true,
117
+ stdout: stdout_buffer.string,
118
+ stderr: stderr_buffer.string,
119
+ value_preview: value_preview(value),
120
+ warnings: warnings
121
+ )
122
+ rescue ::Timeout::Error
123
+ ExecutionResult.new(
124
+ ok: false,
125
+ stdout: stdout_buffer.string,
126
+ stderr: stderr_buffer.string,
127
+ error_class: "Timeout::Error",
128
+ error_message: "Execution exceeded #{@timeout_seconds} seconds",
129
+ backtrace_excerpt: [],
130
+ warnings: warnings
131
+ )
132
+ rescue StandardError, ScriptError => e
133
+ ExecutionResult.new(
134
+ ok: false,
135
+ stdout: stdout_buffer.string,
136
+ stderr: stderr_buffer.string,
137
+ error_class: e.class.name,
138
+ error_message: e.message,
139
+ backtrace_excerpt: Array(e.backtrace).first(5),
140
+ warnings: warnings
141
+ )
142
+ ensure
143
+ $stdout = prior_stdout
144
+ $stderr = prior_stderr
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def install_helpers
151
+ query_proc = @llm_query_proc
152
+ fetch_proc = method(:fetch_url)
153
+ sh_proc = method(:run_shell)
154
+ patch_proc = method(:patch_file_safely)
155
+ grep_proc = method(:grep_codebase)
156
+ chunk_proc = method(:chunk_text_semantically)
157
+
158
+ @host.define_singleton_method(:context) do
159
+ @context
160
+ end
161
+ @host.define_singleton_method(:llm_query) do |sub_prompt, model_name: nil|
162
+ query_proc.call(sub_prompt, model_name: model_name)
163
+ end
164
+ @host.define_singleton_method(:fetch) do |url, headers: {}|
165
+ fetch_proc.call(url, headers: headers)
166
+ end
167
+ @host.define_singleton_method(:sh) do |command, timeout: 5|
168
+ sh_proc.call(command, timeout: timeout)
169
+ end
170
+ @host.define_singleton_method(:patch_file) do |path, old_text, new_text|
171
+ patch_proc.call(path, old_text, new_text)
172
+ end
173
+ @host.define_singleton_method(:grep) do |pattern, path: "."|
174
+ grep_proc.call(pattern, path: path)
175
+ end
176
+ @host.define_singleton_method(:chunk_text) do |text, max_length: 2000|
177
+ chunk_proc.call(text, max_length: max_length)
178
+ end
179
+ end
180
+
181
+ def fetch_url(url, headers: {}, max_redirects: 5)
182
+ raise ArgumentError, "URL is required" if url.to_s.strip.empty?
183
+ raise RuntimeError, "Too many redirects" if max_redirects.negative?
184
+
185
+ uri = URI.parse(url.to_s)
186
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
187
+ raise ArgumentError, "Unsupported URL scheme: #{uri.scheme.inspect}"
188
+ end
189
+
190
+ request = Net::HTTP::Get.new(uri)
191
+ headers.to_h.each { |key, value| request[key.to_s] = value.to_s }
192
+
193
+ response = with_http(uri) { |http| http.request(request) }
194
+ if response.is_a?(Net::HTTPRedirection)
195
+ location = response["location"].to_s
196
+ raise RuntimeError, "Redirect response missing Location header" if location.empty?
197
+
198
+ redirected_url = URI.join(uri.to_s, location).to_s
199
+ return fetch_url(redirected_url, headers: headers, max_redirects: max_redirects - 1)
200
+ end
201
+
202
+ status = response.code.to_i
203
+ body = response.body.to_s
204
+ raise RuntimeError, "HTTP #{status}: #{truncate_text(body, 500)}" unless status.between?(200, 299)
205
+
206
+ parse_http_body(body, content_type: response["content-type"])
207
+ end
208
+
209
+ def run_shell(command, timeout: 5)
210
+ cmd = command.to_s
211
+ raise ArgumentError, "command is required" if cmd.strip.empty?
212
+
213
+ timeout_seconds = timeout.to_f
214
+ raise ArgumentError, "timeout must be > 0" unless timeout_seconds.positive?
215
+
216
+ stdout_text = +""
217
+ stderr_text = +""
218
+ status = nil
219
+ timed_out = false
220
+
221
+ Open3.popen3("sh", "-lc", cmd, chdir: @workspace_root.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thr|
222
+ stdin.close
223
+ stdout_reader = Thread.new { stdout.read.to_s }
224
+ stderr_reader = Thread.new { stderr.read.to_s }
225
+
226
+ begin
227
+ Timeout.timeout(timeout_seconds) { status = wait_thr.value }
228
+ rescue Timeout::Error
229
+ timed_out = true
230
+ terminate_process_group(wait_thr.pid)
231
+ status = wait_thr.value rescue nil
232
+ ensure
233
+ stdout_text = stdout_reader.value
234
+ stderr_text = stderr_reader.value
235
+ end
236
+ end
237
+
238
+ {
239
+ stdout: stdout_text,
240
+ stderr: stderr_text,
241
+ exit_code: status&.exitstatus,
242
+ ok: !timed_out && status&.success? == true,
243
+ timed_out: timed_out
244
+ }
245
+ end
246
+
247
+ def patch_file_safely(path, old_text, new_text)
248
+ target = resolve_workspace_path(path)
249
+ raise ArgumentError, "File not found: #{path}" unless target.file?
250
+
251
+ needle = old_text.to_s
252
+ raise ArgumentError, "old_text cannot be empty" if needle.empty?
253
+
254
+ content = File.read(target)
255
+ occurrences = content.split(needle, -1).length - 1
256
+ unless occurrences == 1
257
+ raise RuntimeError, "old_text must appear exactly once (found #{occurrences})"
258
+ end
259
+
260
+ # Track modification for audit trail and undo
261
+ @modifications << {
262
+ path: target.to_s,
263
+ relative_path: to_relative_path(target),
264
+ old_text: needle,
265
+ new_text: new_text.to_s,
266
+ timestamp: Time.now.iso8601
267
+ }
268
+
269
+ updated = content.sub(needle, new_text.to_s)
270
+ File.write(target, updated)
271
+
272
+ { path: to_relative_path(target), replaced: 1, bytes_written: updated.bytesize }
273
+ end
274
+
275
+ def undo_last_patch
276
+ mod = @modifications.pop
277
+ return nil unless mod
278
+
279
+ content = File.read(mod[:path])
280
+ restored = content.sub(mod[:new_text], mod[:old_text])
281
+ File.write(mod[:path], restored)
282
+ { path: mod[:relative_path], restored: true }
283
+ end
284
+
285
+ def undo_all_patches
286
+ results = []
287
+ results << undo_last_patch until @modifications.empty?
288
+ results.compact
289
+ end
290
+
291
+ def grep_codebase(pattern, path: ".")
292
+ query = pattern.to_s
293
+ raise ArgumentError, "pattern is required" if query.strip.empty?
294
+
295
+ search_root = resolve_workspace_path(path)
296
+ raise ArgumentError, "path not found: #{path}" unless search_root.exist?
297
+
298
+ search_path = to_relative_path(search_root)
299
+ search_path = "." if search_path.empty?
300
+
301
+ stdout_text, stderr_text, status = Open3.capture3(
302
+ "rg",
303
+ "--line-number",
304
+ "--with-filename",
305
+ "--no-heading",
306
+ "--color",
307
+ "never",
308
+ query,
309
+ search_path,
310
+ chdir: @workspace_root.to_s
311
+ )
312
+
313
+ return [] if status.exitstatus == 1
314
+ if status.exitstatus != 0
315
+ message = stderr_text.to_s.strip
316
+ message = stdout_text.to_s.strip if message.empty?
317
+ raise RuntimeError, "grep failed: #{message}"
318
+ end
319
+
320
+ stdout_text.lines.map do |line|
321
+ file, line_no, text = line.chomp.split(":", 3)
322
+ { path: file, line: line_no.to_i, text: text.to_s }
323
+ end
324
+ end
325
+
326
+ def chunk_text_semantically(text, max_length: 2000)
327
+ max = Integer(max_length)
328
+ raise ArgumentError, "max_length must be > 0" unless max.positive?
329
+
330
+ normalized = text.to_s.gsub(/\r\n?/, "\n").strip
331
+ return [] if normalized.empty?
332
+
333
+ segments = normalized.split(/\n{2,}/).map(&:strip).reject(&:empty?).flat_map do |paragraph|
334
+ split_semantic_unit(paragraph, max)
335
+ end
336
+
337
+ chunks = []
338
+ current = +""
339
+ segments.each do |segment|
340
+ if current.empty?
341
+ current = segment
342
+ elsif (current.length + 2 + segment.length) <= max
343
+ current = "#{current}\n\n#{segment}"
344
+ else
345
+ chunks << current
346
+ current = segment
347
+ end
348
+ end
349
+ chunks << current unless current.empty?
350
+ chunks
351
+ end
352
+
353
+ def value_preview(value)
354
+ text = value.inspect
355
+ return text if text.length <= 500
356
+
357
+ "#{text[0, 500]}...<truncated>"
358
+ end
359
+
360
+ def with_http(uri)
361
+ http = Net::HTTP.new(uri.host, uri.port)
362
+ http.use_ssl = uri.is_a?(URI::HTTPS)
363
+ http.open_timeout = 10
364
+ http.read_timeout = 30
365
+ http.start { |session| yield session }
366
+ end
367
+
368
+ def parse_http_body(body, content_type:)
369
+ json_like = content_type.to_s.include?("application/json") || body.lstrip.start_with?("{", "[")
370
+ return body unless json_like
371
+
372
+ parsed = JSON.parse(body)
373
+ deep_indifferentize(parsed)
374
+ rescue JSON::ParserError
375
+ body
376
+ end
377
+
378
+ def truncate_text(text, max)
379
+ return text if text.length <= max
380
+
381
+ "#{text[0, max]}...<truncated>"
382
+ end
383
+
384
+ def terminate_process_group(pid)
385
+ return if pid.nil? || pid <= 0
386
+
387
+ target = Gem.win_platform? ? pid : -pid
388
+ Process.kill("TERM", target)
389
+ sleep 0.1
390
+ rescue Errno::EPERM, Errno::ESRCH
391
+ nil
392
+ ensure
393
+ Process.kill("KILL", target) rescue nil
394
+ end
395
+
396
+ def resolve_workspace_path(path)
397
+ value = path.to_s
398
+ raise ArgumentError, "path is required" if value.strip.empty?
399
+
400
+ candidate = Pathname.new(value)
401
+ absolute = candidate.absolute? ? candidate.expand_path : @workspace_root.join(candidate).expand_path
402
+ root = @workspace_root.to_s
403
+ absolute_str = absolute.to_s
404
+ if absolute_str == root || absolute_str.start_with?("#{root}#{File::SEPARATOR}")
405
+ absolute
406
+ else
407
+ raise ArgumentError, "Path escapes workspace root: #{path}"
408
+ end
409
+ end
410
+
411
+ def to_relative_path(pathname)
412
+ pathname.relative_path_from(@workspace_root).to_s
413
+ rescue StandardError
414
+ pathname.to_s
415
+ end
416
+
417
+ def split_semantic_unit(text, max_length)
418
+ return [text] if text.length <= max_length
419
+
420
+ sentences = text.scan(/[^.!?\n]+(?:[.!?]+|$)/).map(&:strip).reject(&:empty?)
421
+ sentences = [text] if sentences.empty?
422
+
423
+ parts = []
424
+ current = +""
425
+ sentences.each do |sentence|
426
+ if sentence.length > max_length
427
+ parts << current unless current.empty?
428
+ current = +""
429
+ parts.concat(hard_wrap(sentence, max_length))
430
+ next
431
+ end
432
+
433
+ if current.empty?
434
+ current = sentence
435
+ elsif (current.length + 1 + sentence.length) <= max_length
436
+ current = "#{current} #{sentence}"
437
+ else
438
+ parts << current
439
+ current = sentence
440
+ end
441
+ end
442
+ parts << current unless current.empty?
443
+ parts
444
+ end
445
+
446
+ def hard_wrap(text, max_length)
447
+ chunks = []
448
+ remaining = text.dup
449
+ until remaining.empty?
450
+ break_point = remaining.rindex(" ", max_length) || max_length
451
+ chunk = remaining[0, break_point].strip
452
+ if chunk.empty?
453
+ chunk = remaining[0, max_length]
454
+ remaining = remaining[max_length..] || +""
455
+ else
456
+ remaining = remaining[break_point..] || +""
457
+ end
458
+ chunks << chunk
459
+ remaining = remaining.lstrip
460
+ end
461
+ chunks
462
+ end
463
+
464
+ def deep_indifferentize(value)
465
+ case value
466
+ when Hash
467
+ IndifferentHash.new(value)
468
+ when Array
469
+ value.map { |item| deep_indifferentize(item) }
470
+ else
471
+ value
472
+ end
473
+ end
474
+ end
475
+ end
476
+ end
@@ -0,0 +1,47 @@
1
+ require "digest"
2
+
3
+ module RubyRLM
4
+ class SubCallCache
5
+ attr_reader :hits, :misses
6
+
7
+ def initialize
8
+ @store = {}
9
+ @hits = 0
10
+ @misses = 0
11
+ end
12
+
13
+ def get(prompt, model_name:)
14
+ key = cache_key(prompt, model_name)
15
+ if @store.key?(key)
16
+ @hits += 1
17
+ @store[key]
18
+ else
19
+ @misses += 1
20
+ nil
21
+ end
22
+ end
23
+
24
+ def put(prompt, model_name:, response:)
25
+ key = cache_key(prompt, model_name)
26
+ @store[key] = response
27
+ end
28
+
29
+ def size
30
+ @store.size
31
+ end
32
+
33
+ def stats
34
+ {
35
+ hits: @hits,
36
+ misses: @misses,
37
+ size: @store.size
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def cache_key(prompt, model_name)
44
+ Digest::SHA256.hexdigest("#{model_name}:#{prompt}")
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ module RubyRLM
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,41 @@
1
+ require "sinatra/base"
2
+ require_relative "services/session_loader"
3
+ require_relative "services/streaming_logger"
4
+ require_relative "services/query_service"
5
+ require_relative "services/export_service"
6
+ require_relative "routes/api"
7
+ require_relative "routes/pages"
8
+ require_relative "routes/sse"
9
+
10
+ module RubyRLM
11
+ module Web
12
+ class App < Sinatra::Base
13
+ set :public_folder, File.expand_path("public", __dir__)
14
+ set :log_dir, ENV.fetch("RUBYRLM_LOG_DIR", "./logs")
15
+ set :bind, "0.0.0.0"
16
+ set :server, :puma
17
+ # Use Puma cluster mode (1 worker) to avoid single-mode boot.
18
+ # Keep worker count at 1 because in-flight runs are tracked in-process.
19
+ set :server_settings, {
20
+ workers: 1,
21
+ silence_single_worker_warning: true
22
+ }
23
+
24
+ configure :development do
25
+ require "sinatra/reloader"
26
+ register Sinatra::Reloader
27
+ also_reload File.expand_path("{services,routes}/*.rb", __dir__)
28
+ end
29
+
30
+ configure do
31
+ set :session_loader, Services::SessionLoader.new(log_dir: settings.log_dir)
32
+ set :query_service, Services::QueryService.new(log_dir: settings.log_dir)
33
+ set :export_service, Services::ExportService.new(session_loader: settings.session_loader)
34
+ end
35
+
36
+ register Routes::Api
37
+ register Routes::Pages
38
+ register Routes::SSE
39
+ end
40
+ end
41
+ end