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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE +21 -0
- data/README.md +300 -0
- data/bin/rubyrlm +168 -0
- data/lib/rubyrlm/backends/base.rb +9 -0
- data/lib/rubyrlm/backends/gemini_rest.rb +317 -0
- data/lib/rubyrlm/client.rb +643 -0
- data/lib/rubyrlm/completion.rb +71 -0
- data/lib/rubyrlm/errors.rb +9 -0
- data/lib/rubyrlm/logger/jsonl_logger.rb +27 -0
- data/lib/rubyrlm/pricing.rb +88 -0
- data/lib/rubyrlm/prompts/system_prompt.rb +108 -0
- data/lib/rubyrlm/protocol/action_parser.rb +84 -0
- data/lib/rubyrlm/repl/code_validator.rb +113 -0
- data/lib/rubyrlm/repl/docker_repl/container_manager.rb +158 -0
- data/lib/rubyrlm/repl/docker_repl/host_rpc_server.rb +164 -0
- data/lib/rubyrlm/repl/docker_repl/protocol.rb +26 -0
- data/lib/rubyrlm/repl/docker_repl.rb +190 -0
- data/lib/rubyrlm/repl/execution_result.rb +41 -0
- data/lib/rubyrlm/repl/local_repl.rb +476 -0
- data/lib/rubyrlm/sub_call_cache.rb +47 -0
- data/lib/rubyrlm/version.rb +3 -0
- data/lib/rubyrlm/web/app.rb +41 -0
- data/lib/rubyrlm/web/public/css/components.css +649 -0
- data/lib/rubyrlm/web/public/css/design-system.css +1396 -0
- data/lib/rubyrlm/web/public/js/app.js +1016 -0
- data/lib/rubyrlm/web/public/js/components/charts.js +68 -0
- data/lib/rubyrlm/web/public/js/components/context-inspector.js +94 -0
- data/lib/rubyrlm/web/public/js/components/exec-chain.js +105 -0
- data/lib/rubyrlm/web/public/js/components/kpi-dashboard.js +187 -0
- data/lib/rubyrlm/web/public/js/components/query-panel.js +335 -0
- data/lib/rubyrlm/web/public/js/components/recursion-tree.js +83 -0
- data/lib/rubyrlm/web/public/js/components/session-list.js +160 -0
- data/lib/rubyrlm/web/public/js/components/step-navigator.js +129 -0
- data/lib/rubyrlm/web/public/js/components/timeline.js +281 -0
- data/lib/rubyrlm/web/public/js/lib/animation.js +46 -0
- data/lib/rubyrlm/web/public/js/lib/chart-renderer.js +116 -0
- data/lib/rubyrlm/web/public/js/lib/diagram-renderer.js +233 -0
- data/lib/rubyrlm/web/public/js/lib/sse-client.js +94 -0
- data/lib/rubyrlm/web/public/js/lib/theme-manager.js +39 -0
- data/lib/rubyrlm/web/public/js/utils.js +57 -0
- data/lib/rubyrlm/web/routes/api.rb +129 -0
- data/lib/rubyrlm/web/routes/pages.rb +365 -0
- data/lib/rubyrlm/web/routes/sse.rb +95 -0
- data/lib/rubyrlm/web/services/event_broadcaster.rb +36 -0
- data/lib/rubyrlm/web/services/export_service.rb +903 -0
- data/lib/rubyrlm/web/services/query_service.rb +221 -0
- data/lib/rubyrlm/web/services/session_loader.rb +356 -0
- data/lib/rubyrlm/web/services/streaming_logger.rb +22 -0
- data/lib/rubyrlm.rb +18 -0
- 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,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
|