rubyn-code 0.7.0 → 0.9.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -17
  3. data/lib/rubyn_code/agent/conversation.rb +11 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +5 -1
  6. data/lib/rubyn_code/agent/loop.rb +57 -3
  7. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +10 -0
  10. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  11. data/lib/rubyn_code/cli/commands/context.rb +27 -0
  12. data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
  13. data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
  14. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  15. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  16. data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
  17. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  18. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  19. data/lib/rubyn_code/cli/first_run.rb +1 -1
  20. data/lib/rubyn_code/cli/mention_expander.rb +19 -0
  21. data/lib/rubyn_code/cli/repl.rb +31 -1
  22. data/lib/rubyn_code/cli/repl_commands.rb +1 -1
  23. data/lib/rubyn_code/cli/repl_setup.rb +8 -6
  24. data/lib/rubyn_code/config/defaults.rb +2 -1
  25. data/lib/rubyn_code/config/schema.json +5 -0
  26. data/lib/rubyn_code/config/settings.rb +4 -2
  27. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  28. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  29. data/lib/rubyn_code/index/codebase_index.rb +64 -3
  30. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  31. data/lib/rubyn_code/learning/injector.rb +1 -2
  32. data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
  33. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  34. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  35. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  36. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  37. data/lib/rubyn_code/llm/client.rb +16 -3
  38. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  39. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  40. data/lib/rubyn_code/llm/model_router.rb +4 -4
  41. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  42. data/lib/rubyn_code/memory/session_persistence.rb +1 -1
  43. data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
  44. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  45. data/lib/rubyn_code/tasks/models.rb +0 -16
  46. data/lib/rubyn_code/teams/teammate.rb +0 -15
  47. data/lib/rubyn_code/tools/RUBYN.md +3 -3
  48. data/lib/rubyn_code/tools/bash.rb +3 -3
  49. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  50. data/lib/rubyn_code/tools/executor.rb +6 -1
  51. data/lib/rubyn_code/tools/phone_a_friend.rb +135 -0
  52. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  53. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  54. data/lib/rubyn_code/version.rb +1 -1
  55. data/lib/rubyn_code.rb +14 -8
  56. data/skills/rubyn_self_test.md +140 -0
  57. metadata +11 -7
  58. data/lib/rubyn_code/context/context_budget.rb +0 -183
  59. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  60. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  61. data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
  62. data/lib/rubyn_code/llm/streaming.rb +0 -10
  63. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -14,6 +14,9 @@ module RubynCode
14
14
  INDEX_DIR = '.rubyn-code'
15
15
  INDEX_FILE = 'codebase_index.json'
16
16
  CHARS_PER_TOKEN = 4
17
+ # Bump when the stored shape changes so stale indexes rebuild instead
18
+ # of silently serving degraded data. 2 = Prism spans + call edges.
19
+ FORMAT_VERSION = 2
17
20
 
18
21
  attr_reader :nodes, :edges, :index_path
19
22
 
@@ -33,6 +36,7 @@ module RubynCode
33
36
 
34
37
  ruby_files.each { |file| index_file(file) }
35
38
  extract_rails_edges
39
+ prune_call_edges!
36
40
  save!
37
41
  self
38
42
  end
@@ -42,6 +46,8 @@ module RubynCode
42
46
  return nil unless File.exist?(@index_path)
43
47
 
44
48
  data = JSON.parse(File.read(@index_path))
49
+ return nil unless data['format_version'] == FORMAT_VERSION
50
+
45
51
  @nodes = data['nodes'] || []
46
52
  # uniq drops duplicate edges accumulated by older versions, which
47
53
  # appended tests edges on every update! without dedup.
@@ -68,6 +74,7 @@ module RubynCode
68
74
  end
69
75
 
70
76
  extract_rails_edges
77
+ prune_call_edges!
71
78
  save!
72
79
  self
73
80
  end
@@ -81,6 +88,7 @@ module RubynCode
81
88
  remove_nodes_for(absolute)
82
89
  index_file(absolute) if File.exist?(absolute)
83
90
  extract_rails_edges
91
+ prune_call_edges!
84
92
  save!
85
93
  self
86
94
  end
@@ -237,14 +245,66 @@ module RubynCode
237
245
  content = File.read(file)
238
246
  @file_mtimes[relative] = File.mtime(file).to_i
239
247
 
240
- extract_classes(content, relative)
241
- extract_methods(content, relative)
248
+ extract_symbols(content, relative)
242
249
  extract_associations(content, relative)
243
250
  extract_rails_patterns(content, relative)
244
251
  rescue StandardError => e
245
252
  RubynCode::Debug.warn("Index: failed to parse #{file}: #{e.message}")
246
253
  end
247
254
 
255
+ # Prism gives real line spans (needed for verbatim source in code_graph)
256
+ # and call edges. Files that don't parse fall back to the regex pass,
257
+ # which produces the same node shapes minus end_line/owner/calls.
258
+ def extract_symbols(content, file)
259
+ extracted = PrismExtractor.extract(content)
260
+ unless extracted
261
+ extract_classes(content, file)
262
+ extract_methods(content, file)
263
+ return
264
+ end
265
+
266
+ add_class_nodes(extracted.classes, file)
267
+ add_method_nodes(extracted.defs, file)
268
+ add_call_edges(extracted.calls, file)
269
+ end
270
+
271
+ def add_class_nodes(classes, file)
272
+ classes.each do |c|
273
+ @nodes << {
274
+ 'type' => classify_node(file, c[:kind]), 'name' => c[:name],
275
+ 'file' => file, 'line' => c[:line], 'end_line' => c[:end_line]
276
+ }
277
+ end
278
+ end
279
+
280
+ def add_method_nodes(methods, file)
281
+ methods.each do |m|
282
+ @nodes << {
283
+ 'type' => 'method', 'name' => m[:name], 'file' => file,
284
+ 'line' => m[:line], 'end_line' => m[:end_line],
285
+ 'owner' => m[:owner], 'params' => m[:params], 'visibility' => 'public'
286
+ }
287
+ end
288
+ end
289
+
290
+ # `from` stays the file path so remove_nodes_for's from-based cleanup
291
+ # applies to call edges too; the calling method rides in from_method.
292
+ def add_call_edges(calls, file)
293
+ calls.each do |c|
294
+ @edges << {
295
+ 'from' => file, 'from_method' => c[:from], 'to' => c[:to],
296
+ 'relationship' => 'calls', 'line' => c[:line]
297
+ }
298
+ end
299
+ end
300
+
301
+ # Drop call edges whose target isn't defined in the project — filters
302
+ # out stdlib/gem calls (puts, map, ...) that would swamp the graph.
303
+ def prune_call_edges!
304
+ defined_methods = @nodes.filter_map { |n| n['name'] if n['type'] == 'method' }.to_set
305
+ @edges.reject! { |e| e['relationship'] == 'calls' && !defined_methods.include?(e['to']) }
306
+ end
307
+
248
308
  def extract_classes(content, file)
249
309
  content.scan(/^\s*(class|module)\s+(\S+)/).each do |type, name|
250
310
  node_type = classify_node(file, type)
@@ -342,7 +402,8 @@ module RubynCode
342
402
 
343
403
  def save!
344
404
  FileUtils.mkdir_p(File.dirname(@index_path))
345
- data = { 'nodes' => @nodes, 'edges' => @edges, 'file_mtimes' => @file_mtimes }
405
+ data = { 'format_version' => FORMAT_VERSION, 'nodes' => @nodes, 'edges' => @edges,
406
+ 'file_mtimes' => @file_mtimes }
346
407
  File.write(@index_path, JSON.generate(data))
347
408
  end
348
409
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module RubynCode
6
+ module Index
7
+ # Prism-based symbol and call extractor for a single Ruby file.
8
+ # Returns classes/modules and methods with real line spans, plus
9
+ # method-to-method call sites the regex pass can't see. Used by
10
+ # CodebaseIndex, with the regex extractors as a parse-error fallback.
11
+ class PrismExtractor < Prism::Visitor
12
+ Result = Struct.new(:classes, :defs, :calls)
13
+
14
+ # @return [Result, nil] nil when the file doesn't parse
15
+ def self.extract(content)
16
+ parsed = Prism.parse(content)
17
+ return nil unless parsed.success?
18
+
19
+ visitor = new
20
+ visitor.visit(parsed.value)
21
+ Result.new(visitor.classes, visitor.defs, visitor.calls)
22
+ rescue StandardError
23
+ nil
24
+ end
25
+
26
+ attr_reader :classes, :defs, :calls
27
+
28
+ def initialize
29
+ super
30
+ @namespace = []
31
+ @current_def = nil
32
+ @classes = [] # { name:, kind:, line:, end_line: }
33
+ @defs = [] # { name:, owner:, line:, end_line:, params: }
34
+ @calls = [] # { from:, to:, line: }
35
+ end
36
+
37
+ def visit_class_node(node)
38
+ record_namespace(node, 'class') { super }
39
+ end
40
+
41
+ def visit_module_node(node)
42
+ record_namespace(node, 'module') { super }
43
+ end
44
+
45
+ def visit_def_node(node)
46
+ @defs << {
47
+ name: node.name.to_s,
48
+ owner: @namespace.join('::'),
49
+ line: node.location.start_line,
50
+ end_line: node.location.end_line,
51
+ params: node.parameters ? "(#{node.parameters.slice})" : nil
52
+ }
53
+ previous = @current_def
54
+ @current_def = node.name.to_s
55
+ super
56
+ @current_def = previous
57
+ end
58
+
59
+ # ponytail: resolution is by method name, not receiver type — a call to
60
+ # `user.save` links to any `save` defined in the project. Real receiver
61
+ # inference needs type analysis; name matching covers the common case.
62
+ def visit_call_node(node)
63
+ @calls << { from: @current_def, to: node.name.to_s, line: node.location.start_line } if @current_def
64
+ super
65
+ end
66
+
67
+ private
68
+
69
+ # The block continues the walk into child nodes (the caller's `super`).
70
+ def record_namespace(node, kind)
71
+ name = node.constant_path.slice
72
+ @classes << {
73
+ name: name, kind: kind,
74
+ line: node.location.start_line, end_line: node.location.end_line
75
+ }
76
+ @namespace.push(name)
77
+ yield
78
+ @namespace.pop
79
+ end
80
+ end
81
+ end
82
+ end
@@ -112,8 +112,7 @@ module RubynCode
112
112
  tag_set = tags.to_set(&:downcase)
113
113
 
114
114
  instincts.select do |inst|
115
- inst_tags = inst.context_tags.map(&:downcase)
116
- inst_tags.any? { |t| tag_set.include?(t) }
115
+ inst.context_tags.any? { |t| tag_set.include?(t.downcase) }
117
116
  end
118
117
  end
119
118
 
@@ -11,20 +11,39 @@ module RubynCode
11
11
  class Anthropic < Base
12
12
  include JsonParsing
13
13
  include PromptCaching
14
- include TokenCaching
15
14
 
16
15
  API_URL = 'https://api.anthropic.com/v1/messages'
17
16
  ANTHROPIC_VERSION = '2023-06-01'
18
17
  MAX_RETRIES = 3
19
18
  RETRY_DELAYS = [2, 5, 10].freeze
20
19
 
20
+ # Fable/Mythos/Opus 5 models opt into a server-side Opus 4.8 fallback
21
+ # by default: a declined request (safety-classifier refusal) is
22
+ # transparently re-served by the fallback model in the same call.
23
+ # No config toggle — hardcoded for these model families only.
24
+ FALLBACK_ELIGIBLE_MODELS = /\Aclaude-(fable|mythos|opus-5)/
25
+ FALLBACK_BETA = 'server-side-fallback-2026-06-01'
26
+
27
+ TASK_BUDGET_BETA = 'task-budgets-2026-03-13'
28
+ TASK_BUDGET_MIN_TOKENS = 20_000 # API minimum; below this, task_budget is omitted entirely
29
+ TASK_BUDGET_MODELS = /\Aclaude-(fable|mythos|sonnet-5|opus-5|opus-4-[78])/
30
+
21
31
  AVAILABLE_MODELS = %w[
22
32
  claude-fable-5
33
+ claude-opus-5
23
34
  claude-opus-4-8
24
- claude-sonnet-4-20250514
25
- claude-haiku-4-20250506
35
+ claude-opus-4-7
36
+ claude-opus-4-6
37
+ claude-sonnet-5
38
+ claude-sonnet-4-6
39
+ claude-haiku-4-5
26
40
  ].freeze
27
41
 
42
+ # Models on the adaptive-thinking API surface (Claude 4.6+).
43
+ # budget_tokens is removed there and returns a 400; older models
44
+ # (Haiku 4.5, Sonnet/Opus 4.5 and earlier) still take enabled + budget.
45
+ ADAPTIVE_THINKING_MODELS = /\Aclaude-(fable|mythos|opus-5|opus-4-[678]|sonnet-5|sonnet-4-6)/
46
+
28
47
  def provider_name
29
48
  'anthropic'
30
49
  end
@@ -33,14 +52,16 @@ module RubynCode
33
52
  AVAILABLE_MODELS
34
53
  end
35
54
 
36
- def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil) # rubocop:disable Metrics/ParameterLists -- mirrors LLM adapter interface
55
+ def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, # rubocop:disable Metrics/ParameterLists -- mirrors LLM adapter interface
56
+ task_budget: nil, thinking: nil, effort: nil)
37
57
  ensure_valid_token!
38
58
  use_streaming = on_text && oauth_token?
39
59
 
40
60
  body = build_request_body(
41
61
  messages: messages, tools: tools, system: system,
42
62
  model: model, max_tokens: max_tokens,
43
- stream: use_streaming, task_budget: task_budget
63
+ stream: use_streaming, task_budget: task_budget,
64
+ thinking: thinking, effort: effort
44
65
  )
45
66
 
46
67
  return stream_request(body, on_text) if use_streaming
@@ -138,7 +159,7 @@ module RubynCode
138
159
 
139
160
  def post_request(body)
140
161
  connection.post(api_url) do |req|
141
- apply_headers(req)
162
+ apply_headers(req, body)
142
163
  req.body = JSON.generate(body)
143
164
  end
144
165
  end
@@ -163,7 +184,7 @@ module RubynCode
163
184
  error_chunks = []
164
185
 
165
186
  response = streaming_connection.post(api_url) do |req|
166
- apply_headers(req)
187
+ apply_headers(req, body)
167
188
  req.body = JSON.generate(body)
168
189
  req.options.on_data = on_data_proc(streamer, error_chunks)
169
190
  end
@@ -189,7 +210,6 @@ module RubynCode
189
210
 
190
211
  error_msg = extract_error_message(error_chunks.join)
191
212
 
192
- invalidate_token_cache! if response.status == 401
193
213
  raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
194
214
  raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
195
215
 
@@ -223,23 +243,35 @@ module RubynCode
223
243
 
224
244
  # -- Headers ------------------------------------------------------
225
245
 
226
- def apply_headers(req)
246
+ def apply_headers(req, body = {})
227
247
  req.headers['Content-Type'] = 'application/json'
228
248
  req.headers['anthropic-version'] = ANTHROPIC_VERSION
229
- oauth_token? ? apply_oauth_headers(req) : apply_api_key_headers(req)
249
+ oauth_token? ? apply_oauth_headers(req, body) : apply_api_key_headers(req, body)
230
250
  end
231
251
 
232
- def apply_oauth_headers(req)
252
+ def apply_oauth_headers(req, body)
233
253
  req.headers['Authorization'] = "Bearer #{access_token}"
234
- req.headers['anthropic-beta'] = 'oauth-2025-04-20'
254
+ # The subscription-auth beta must stay present, so feature betas append rather than replace.
255
+ betas = ['oauth-2025-04-20']
256
+ betas << TASK_BUDGET_BETA if task_budget_on_wire?(body)
257
+ betas << FALLBACK_BETA if body[:fallbacks]
258
+ req.headers['anthropic-beta'] = betas.join(',')
235
259
  req.headers['x-app'] = 'cli'
236
260
  req.headers['User-Agent'] = 'claude-code/2.1.79'
237
261
  req.headers['X-Claude-Code-Session-Id'] = session_id
238
262
  req.headers['anthropic-dangerous-direct-browser-access'] = 'true'
239
263
  end
240
264
 
241
- def apply_api_key_headers(req)
265
+ def apply_api_key_headers(req, body)
242
266
  req.headers['x-api-key'] = access_token
267
+ betas = []
268
+ betas << TASK_BUDGET_BETA if task_budget_on_wire?(body)
269
+ betas << FALLBACK_BETA if body[:fallbacks]
270
+ req.headers['anthropic-beta'] = betas.join(',') unless betas.empty?
271
+ end
272
+
273
+ def task_budget_on_wire?(body)
274
+ !!body&.dig(:output_config, :task_budget)
243
275
  end
244
276
 
245
277
  def session_id
@@ -248,15 +280,70 @@ module RubynCode
248
280
 
249
281
  # -- Request body -------------------------------------------------
250
282
 
251
- def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, **_opts) # rubocop:disable Metrics/ParameterLists -- API request builder mirrors Claude API params
252
- body = { model: model, max_tokens: max_tokens }
283
+ def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:, # rubocop:disable Metrics/ParameterLists -- API request builder mirrors Claude API params
284
+ thinking: nil, effort: nil, task_budget: nil, **_opts)
285
+ body = { model: model, max_tokens: ensure_max_tokens_for_thinking(max_tokens, thinking) }
286
+ apply_thinking(body, thinking)
287
+ apply_effort(body, effort)
288
+ apply_task_budget(body, task_budget, model)
253
289
  apply_system_blocks(body, system)
254
290
  apply_tool_cache(body, tools)
291
+ apply_fallbacks(body, model)
255
292
  body[:messages] = add_message_cache_breakpoint(messages)
256
293
  body[:stream] = true if stream
257
294
  body
258
295
  end
259
296
 
297
+ def apply_fallbacks(body, model)
298
+ return unless model.to_s.match?(FALLBACK_ELIGIBLE_MODELS)
299
+
300
+ body[:fallbacks] = [{ model: 'claude-opus-4-8' }]
301
+ end
302
+
303
+ # Advisory pacing signal only — Claude sees a running countdown and paces
304
+ # itself, but max_tokens remains the enforced hard cap per response.
305
+ def apply_task_budget(body, task_budget, model)
306
+ return unless task_budget.is_a?(Hash)
307
+ return unless model.to_s.match?(TASK_BUDGET_MODELS)
308
+
309
+ # `remaining` is what's left for the task, matching what the model should pace against.
310
+ total = task_budget[:remaining].to_i
311
+ return if total < TASK_BUDGET_MIN_TOKENS
312
+
313
+ (body[:output_config] ||= {})[:task_budget] = { type: 'tokens', total: total }
314
+ end
315
+
316
+ def apply_thinking(body, thinking)
317
+ return unless thinking.is_a?(Hash) && thinking[:budget_tokens].to_i.positive?
318
+
319
+ body[:thinking] =
320
+ if body[:model].to_s.match?(ADAPTIVE_THINKING_MODELS)
321
+ { type: 'adaptive' }
322
+ else
323
+ { type: 'enabled', budget_tokens: thinking[:budget_tokens].to_i }
324
+ end
325
+ end
326
+
327
+ # Merges into `output_config` (rather than assigning a whole hash) so
328
+ # this composes with other output_config keys, e.g. task_budget.
329
+ def apply_effort(body, effort)
330
+ return unless effort
331
+
332
+ (body[:output_config] ||= {})[:effort] = effort
333
+ end
334
+
335
+ # Anthropic requires max_tokens > budget_tokens. When thinking is on,
336
+ # raise max_tokens to budget + 1024 if not already large enough.
337
+ def ensure_max_tokens_for_thinking(max_tokens, thinking)
338
+ return max_tokens unless thinking.is_a?(Hash)
339
+
340
+ budget = thinking[:budget_tokens].to_i
341
+ return max_tokens if budget.zero?
342
+ return max_tokens if max_tokens >= budget + 1024
343
+
344
+ budget + 1024
345
+ end
346
+
260
347
  # -- Response parsing ---------------------------------------------
261
348
 
262
349
  def handle_api_response(response)
@@ -274,7 +361,6 @@ module RubynCode
274
361
  error_type = body&.dig('error', 'type') || 'api_error'
275
362
 
276
363
  log_api_error(response)
277
- invalidate_token_cache! if response.status == 401
278
364
  raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
279
365
  raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
280
366
 
@@ -293,13 +379,17 @@ module RubynCode
293
379
  def build_api_response(body)
294
380
  content = parse_content_blocks(body['content'])
295
381
  usage = parse_usage(body['usage'])
296
- Response.new(id: body['id'], content: content, stop_reason: body['stop_reason'], usage: usage)
382
+ Response.new(
383
+ id: body['id'], content: content, stop_reason: body['stop_reason'],
384
+ stop_details: body['stop_details'], usage: usage
385
+ )
297
386
  end
298
387
 
299
388
  def parse_content_blocks(blocks)
300
389
  (blocks || []).filter_map do |block|
301
390
  case block['type']
302
391
  when 'text' then TextBlock.new(text: block['text'])
392
+ when 'thinking' then ThinkingBlock.new(text: block['thinking'] || block['text'])
303
393
  when 'tool_use'
304
394
  ToolUseBlock.new(id: block['id'], name: block['name'], input: block['input'])
305
395
  end
@@ -35,8 +35,10 @@ module RubynCode
35
35
  @content_blocks = []
36
36
  @current_block_index = nil
37
37
  @current_text = +''
38
+ @current_thinking_text = +''
38
39
  @current_tool_input_json = +''
39
40
  @stop_reason = nil
41
+ @stop_details = nil
40
42
  @usage = nil
41
43
  end
42
44
 
@@ -51,6 +53,7 @@ module RubynCode
51
53
  id: @response_id,
52
54
  content: @content_blocks.compact,
53
55
  stop_reason: @stop_reason,
56
+ stop_details: @stop_details,
54
57
  usage: @usage
55
58
  )
56
59
  end
@@ -107,6 +110,8 @@ module RubynCode
107
110
  case block['type']
108
111
  when 'text'
109
112
  @current_text = +(block['text'] || '')
113
+ when 'thinking'
114
+ @current_thinking_text = +(block['thinking'] || '')
110
115
  when 'tool_use'
111
116
  @current_tool_id = block['id']
112
117
  @current_tool_name = block['name']
@@ -124,6 +129,10 @@ module RubynCode
124
129
  text = delta['text'] || ''
125
130
  @current_text << text
126
131
  emit(:text_delta, { index: data['index'], text: text })
132
+ when 'thinking_delta'
133
+ text = delta['thinking'] || ''
134
+ @current_thinking_text << text
135
+ emit(:thinking_delta, { index: data['index'], text: text })
127
136
  when 'input_json_delta'
128
137
  json_chunk = delta['partial_json'] || ''
129
138
  @current_tool_input_json << json_chunk
@@ -141,6 +150,7 @@ module RubynCode
141
150
  def handle_message_delta(data)
142
151
  delta = data['delta'] || {}
143
152
  @stop_reason = delta['stop_reason'] if delta['stop_reason']
153
+ @stop_details = delta['stop_details'] if delta['stop_details']
144
154
  update_output_tokens(data['usage']) if data['usage']
145
155
  emit(:message_delta, data)
146
156
  end
@@ -167,6 +177,9 @@ module RubynCode
167
177
  elsif !@current_text.empty?
168
178
  @content_blocks[index] = TextBlock.new(text: @current_text.dup)
169
179
  @current_text = +''
180
+ elsif !@current_thinking_text.empty?
181
+ @content_blocks[index] = ThinkingBlock.new(text: @current_thinking_text.dup)
182
+ @current_thinking_text = +''
170
183
  end
171
184
  end
172
185
 
@@ -15,8 +15,9 @@ module RubynCode
15
15
  # @param system [String, nil] System prompt text
16
16
  # @param on_text [Proc, nil] Streaming text callback
17
17
  # @param task_budget [Hash, nil] Optional task budget context
18
+ # @param thinking [Hash, nil] Optional {budget_tokens: Integer} for extended reasoning
18
19
  # @return [LLM::Response]
19
- def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil) # rubocop:disable Metrics/ParameterLists -- LLM adapter interface requires these params
20
+ def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists -- LLM adapter interface requires these params
20
21
  raise NotImplementedError, "#{self.class}#chat must be implemented"
21
22
  end
22
23
 
@@ -30,7 +30,7 @@ module RubynCode
30
30
  AVAILABLE_MODELS
31
31
  end
32
32
 
33
- def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil) # rubocop:disable Metrics/ParameterLists, Lint/UnusedMethodArgument -- LLM adapter interface requires these params
33
+ def chat(messages:, model:, max_tokens:, tools: nil, system: nil, on_text: nil, task_budget: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists, Lint/UnusedMethodArgument -- LLM adapter interface requires these params
34
34
  body = build_request_body(
35
35
  messages: messages, model: model, max_tokens: max_tokens,
36
36
  tools: tools, system: system
@@ -27,6 +27,10 @@ module RubynCode
27
27
  return translate_tool_results(content) if tool_results?(content)
28
28
  return translate_assistant_tool_use(content) if role == 'assistant' && tool_use_blocks?(content)
29
29
 
30
+ if content.is_a?(Array) && content.any? { |b| block_type(b) == 'image' }
31
+ return { role: role, content: translate_user_content_with_images(content) }
32
+ end
33
+
30
34
  { role: role, content: stringify_content(content) }
31
35
  end
32
36
 
@@ -34,6 +38,23 @@ module RubynCode
34
38
  content.is_a?(Array) && content.any? { |b| block_type(b) == 'tool_result' }
35
39
  end
36
40
 
41
+ # Emit OpenAI multipart content: text blocks stay text, image blocks
42
+ # become {type: 'image_url', image_url: {url: 'data:<media>;base64,...'}}.
43
+ def translate_user_content_with_images(content)
44
+ content.map do |block|
45
+ case block_type(block)
46
+ when 'image'
47
+ source = block[:source] || block['source']
48
+ media = source[:media_type] || source['media_type']
49
+ data = source[:data] || source['data']
50
+ { type: 'image_url', image_url: { url: "data:#{media};base64,#{data}" } }
51
+ else
52
+ text = block[:text] || block['text'] || ''
53
+ { type: 'text', text: text }
54
+ end
55
+ end
56
+ end
57
+
37
58
  def tool_use_blocks?(content)
38
59
  content.is_a?(Array) && content.any? { |b| block_type(b) == 'tool_use' }
39
60
  end
@@ -15,20 +15,22 @@ module RubynCode
15
15
  class PromptTooLongError < RequestError; end
16
16
 
17
17
  attr_reader :adapter
18
- attr_accessor :model
18
+ attr_accessor :model, :thinking_budget_tokens, :effort
19
19
 
20
20
  def initialize(model: nil, provider: nil, adapter: nil)
21
21
  settings = Config::Settings.new
22
22
  @model = model || settings.model
23
23
  @provider = provider || settings.provider
24
24
  @adapter = adapter || resolve_adapter(@provider)
25
+ @thinking_budget_tokens = 0
25
26
  end
26
27
 
27
28
  def chat(messages:, tools: nil, system: nil, model: nil, **opts)
28
29
  effective_model = model || @model
29
30
  max_tokens = opts[:max_tokens] || Config::Defaults::CAPPED_MAX_OUTPUT_TOKENS
31
+ effective_thinking = opts[:thinking] || current_thinking_hash
30
32
 
31
- @adapter.chat(
33
+ kwargs = {
32
34
  messages: messages,
33
35
  tools: tools,
34
36
  system: system,
@@ -36,7 +38,18 @@ module RubynCode
36
38
  max_tokens: max_tokens,
37
39
  on_text: opts[:on_text],
38
40
  task_budget: opts[:task_budget]
39
- )
41
+ }
42
+ kwargs[:thinking] = effective_thinking if effective_thinking
43
+ kwargs[:effort] = @effort if @effort
44
+
45
+ @adapter.chat(**kwargs)
46
+ end
47
+
48
+ def current_thinking_hash
49
+ budget = @thinking_budget_tokens.to_i
50
+ return nil unless budget.positive?
51
+
52
+ { budget_tokens: budget }
40
53
  end
41
54
 
42
55
  def stream(messages:, tools: nil, system: nil, model: nil,
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module RubynCode
6
+ # Pulls in ImageBlock so the constant is loaded before ImageReader resolves it.
7
+ module LLM
8
+ autoload :ImageBlock, 'rubyn_code/llm/message_builder'
9
+ end
10
+
11
+ module LLM
12
+ # Reads image files from disk and returns image content blocks suitable for
13
+ # sending to the LLM as part of a user turn. Supports common raster formats
14
+ # accepted by both Anthropic and OpenAI vision APIs.
15
+ module ImageReader
16
+ MAX_BYTES = 8 * 1024 * 1024
17
+
18
+ MEDIA_TYPES = {
19
+ '.png' => 'image/png',
20
+ '.jpg' => 'image/jpeg',
21
+ '.jpeg' => 'image/jpeg',
22
+ '.gif' => 'image/gif',
23
+ '.webp' => 'image/webp'
24
+ }.freeze
25
+
26
+ EXTENSIONS_REGEX = /\.(png|jpe?g|gif|webp)\z/i
27
+
28
+ module_function
29
+
30
+ # Build a base64 data URI of the form:
31
+ # "data:image/png;base64,iVBORw0KG..."
32
+ # Returns nil for non-image paths or unreadable/oversized files.
33
+ def data_uri(path)
34
+ block = for_path(path)
35
+ return nil unless block
36
+
37
+ "data:#{block.media_type};base64,#{block.data}"
38
+ end
39
+
40
+ # @return [LLM::ImageBlock, nil] nil for non-image / unreadable paths
41
+ def for_path(path)
42
+ ext = File.extname(path)
43
+ media = MEDIA_TYPES[ext.downcase] || MEDIA_TYPES[".#{ext.sub(/^\./, '').downcase}"]
44
+ return nil unless media
45
+ return nil unless File.file?(path)
46
+
47
+ bytes = File.binread(path)
48
+ return nil if bytes.bytesize > MAX_BYTES
49
+
50
+ ImageBlock.new(media_type: media, data: Base64.strict_encode64(bytes))
51
+ rescue Errno::ENOENT, Errno::EACCES, ArgumentError
52
+ nil
53
+ end
54
+
55
+ def image_extension?(path)
56
+ path.to_s.match?(EXTENSIONS_REGEX)
57
+ end
58
+ end
59
+ end
60
+ end