rubycode 0.1.3 → 0.1.4

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +38 -0
  3. data/.rubocop.yml +4 -0
  4. data/CHANGELOG.md +52 -0
  5. data/README.md +101 -23
  6. data/USAGE.md +1 -23
  7. data/config/locales/en.yml +208 -1
  8. data/config/system_prompt.md +6 -1
  9. data/config/tools/bash.json +1 -1
  10. data/config/tools/fetch.json +22 -0
  11. data/config/tools/websearch.json +22 -0
  12. data/docs/images/demo.png +0 -0
  13. data/lib/rubycode/adapters/base.rb +92 -2
  14. data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
  15. data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
  16. data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
  17. data/lib/rubycode/adapters/deepseek.rb +97 -0
  18. data/lib/rubycode/adapters/gemini.rb +133 -0
  19. data/lib/rubycode/adapters/ollama.rb +114 -82
  20. data/lib/rubycode/adapters/openai.rb +97 -0
  21. data/lib/rubycode/adapters/openrouter.rb +102 -0
  22. data/lib/rubycode/agent_loop.rb +110 -18
  23. data/lib/rubycode/client/approval_handler.rb +14 -0
  24. data/lib/rubycode/client/display_formatter.rb +18 -10
  25. data/lib/rubycode/client/response_handler.rb +4 -23
  26. data/lib/rubycode/client.rb +9 -0
  27. data/lib/rubycode/config_manager.rb +81 -0
  28. data/lib/rubycode/configuration.rb +21 -10
  29. data/lib/rubycode/database.rb +19 -0
  30. data/lib/rubycode/errors.rb +12 -0
  31. data/lib/rubycode/models/api_key.rb +118 -0
  32. data/lib/rubycode/models/memory.rb +84 -10
  33. data/lib/rubycode/models.rb +1 -0
  34. data/lib/rubycode/pricing.rb +59 -0
  35. data/lib/rubycode/search_providers/base.rb +66 -0
  36. data/lib/rubycode/search_providers/brave_search.rb +60 -0
  37. data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
  38. data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
  39. data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
  40. data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
  41. data/lib/rubycode/search_providers/exa_ai.rb +171 -0
  42. data/lib/rubycode/search_providers/multi_provider.rb +47 -0
  43. data/lib/rubycode/token_counter.rb +41 -0
  44. data/lib/rubycode/tools/bash.rb +38 -8
  45. data/lib/rubycode/tools/fetch.rb +120 -0
  46. data/lib/rubycode/tools/web_search.rb +122 -0
  47. data/lib/rubycode/tools.rb +5 -1
  48. data/lib/rubycode/value_objects.rb +8 -4
  49. data/lib/rubycode/version.rb +1 -1
  50. data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
  51. data/lib/rubycode/views/adapter/debug_request.rb +33 -0
  52. data/lib/rubycode/views/adapter/debug_response.rb +31 -0
  53. data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
  54. data/lib/rubycode/views/agent_loop.rb +2 -0
  55. data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
  56. data/lib/rubycode/views/cli/config_saved.rb +17 -0
  57. data/lib/rubycode/views/cli/configuration_table.rb +3 -3
  58. data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
  59. data/lib/rubycode/views/cli/restart_message.rb +17 -0
  60. data/lib/rubycode/views/cli/setup_title.rb +17 -0
  61. data/lib/rubycode/views/cli.rb +5 -0
  62. data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
  63. data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
  64. data/lib/rubycode/views/formatter.rb +2 -0
  65. data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
  66. data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
  67. data/lib/rubycode/views/web_search_approval.rb +29 -0
  68. data/lib/rubycode/views.rb +5 -0
  69. data/lib/rubycode.rb +10 -0
  70. data/rubycode_cli.rb +228 -32
  71. metadata +81 -1
@@ -1,15 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "concerns/error_handling"
4
+ require_relative "concerns/http_client"
5
+
3
6
  module RubyCode
4
7
  module Adapters
5
8
  # Base adapter class for LLM integrations
6
9
  class Base
10
+ include Concerns::ErrorHandling
11
+ include Concerns::HttpClient
12
+
13
+ attr_reader :last_request_time, :current_request_tokens, :total_tokens_counter
14
+
7
15
  def initialize(config)
8
16
  @config = config
17
+ @last_request_time = nil
18
+ @current_request_tokens = nil
19
+ @total_tokens_counter = TokenCounter.new
20
+ end
21
+
22
+ def generate(messages:, system: nil, tools: nil)
23
+ enforce_rate_limit_delay
24
+
25
+ uri = build_uri
26
+ payload = build_payload(messages, system, tools)
27
+ request = build_request(uri, payload)
28
+
29
+ body = send_request_with_retry(uri, request)
30
+
31
+ @last_request_time = Time.now
32
+
33
+ # Extract and track tokens
34
+ @current_request_tokens = extract_tokens(body)
35
+ @total_tokens_counter += @current_request_tokens
36
+
37
+ convert_response(body)
38
+ end
39
+
40
+ private
41
+
42
+ # Abstract methods to be implemented by subclasses
43
+ def adapter_name
44
+ raise NotImplementedError, "Subclass must define adapter_name"
45
+ end
46
+
47
+ def build_payload(_messages, _system, _tools)
48
+ raise NotImplementedError, "Subclass must define build_payload"
49
+ end
50
+
51
+ def build_request(_uri, _payload)
52
+ raise NotImplementedError, "Subclass must define build_request"
53
+ end
54
+
55
+ def convert_response(_raw_response)
56
+ raise NotImplementedError, "Subclass must define convert_response"
57
+ end
58
+
59
+ def api_endpoint
60
+ raise NotImplementedError, "Subclass must define api_endpoint"
61
+ end
62
+
63
+ def extract_tokens(_response_body)
64
+ raise NotImplementedError, "Subclass must implement extract_tokens"
65
+ end
66
+
67
+ # Rate limiting enforcement
68
+ def enforce_rate_limit_delay
69
+ return unless @last_request_time
70
+
71
+ elapsed = Time.now - @last_request_time
72
+ min_delay = @config.adapter_request_delay || 1.5
73
+
74
+ return unless elapsed < min_delay
75
+
76
+ sleep_time = min_delay - elapsed
77
+ sleep(sleep_time)
78
+ end
79
+
80
+ # Concrete shared methods
81
+ def api_key
82
+ # Check database first
83
+ db_key = Models::ApiKey.get_key(adapter: adapter_symbol)
84
+ return db_key if db_key
85
+
86
+ # Fall back to environment variable
87
+ ENV.fetch("#{adapter_name.upcase}_API_KEY", nil)
88
+ end
89
+
90
+ def adapter_symbol
91
+ # Convert class name to symbol: Openai -> :openai
92
+ self.class.name.split("::").last.downcase.to_sym
93
+ end
94
+
95
+ def validate_api_key!
96
+ return if api_key
97
+
98
+ raise AdapterError, I18n.t("rubycode.errors.#{adapter_symbol}.api_key_missing")
9
99
  end
10
100
 
11
- def generate(prompt:)
12
- raise NotImplementedError
101
+ def build_uri
102
+ URI(api_endpoint)
13
103
  end
14
104
  end
15
105
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module Adapters
5
+ module Concerns
6
+ # Debug output for adapter requests and responses
7
+ module Debugging
8
+ private
9
+
10
+ def debug_request(uri, payload)
11
+ puts Views::Adapter::DebugRequest.build(
12
+ adapter_name: adapter_name,
13
+ url: sanitize_url(uri),
14
+ model: @config.model,
15
+ payload: payload
16
+ )
17
+ end
18
+
19
+ def debug_response(body)
20
+ puts Views::Adapter::DebugResponse.build(
21
+ adapter_name: adapter_name,
22
+ body: body
23
+ )
24
+ end
25
+
26
+ def sanitize_url(uri)
27
+ uri.to_s
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module Adapters
5
+ module Concerns
6
+ # Error handling for adapter HTTP responses
7
+ module ErrorHandling
8
+ private
9
+
10
+ def handle_network_error(error, uri)
11
+ case error
12
+ when Net::ReadTimeout
13
+ raise_timeout_error(:read_timeout, @config.http_read_timeout, error)
14
+ when Net::OpenTimeout
15
+ raise_timeout_error(:open_timeout, @config.http_open_timeout, error)
16
+ when Errno::ECONNREFUSED
17
+ raise_connection_error(:connection_refused, uri, error)
18
+ when Errno::ETIMEDOUT
19
+ raise_connection_error(:connection_timeout, uri, error)
20
+ when SocketError
21
+ raise AdapterConnectionError,
22
+ I18n.t("rubycode.errors.adapter.host_unreachable",
23
+ hostname: uri.hostname,
24
+ error: error.message)
25
+ end
26
+ end
27
+
28
+ def raise_timeout_error(key, timeout, error)
29
+ raise AdapterTimeoutError,
30
+ I18n.t("rubycode.errors.adapter.#{key}",
31
+ timeout: timeout,
32
+ error: error.message)
33
+ end
34
+
35
+ def raise_connection_error(key, uri, error)
36
+ raise AdapterConnectionError,
37
+ I18n.t("rubycode.errors.adapter.#{key}",
38
+ uri: uri,
39
+ error: error.message)
40
+ end
41
+
42
+ def handle_response(response)
43
+ body = JSON.parse(response.body)
44
+
45
+ case response.code.to_i
46
+ when 200..299
47
+ body
48
+ when 401, 403
49
+ raise_auth_error(response.code)
50
+ when 429
51
+ raise_rate_limit_error(response)
52
+ when 500..599
53
+ raise_server_error(response)
54
+ else
55
+ raise_http_error(response)
56
+ end
57
+ end
58
+
59
+ def raise_auth_error(code)
60
+ raise AdapterError,
61
+ I18n.t("rubycode.errors.adapter.auth_failed",
62
+ code: code,
63
+ adapter_name: adapter_name.upcase)
64
+ end
65
+
66
+ def raise_rate_limit_error(response)
67
+ raise AdapterConnectionError,
68
+ I18n.t("rubycode.errors.adapter.rate_limited",
69
+ code: response.code,
70
+ message: response.message)
71
+ end
72
+
73
+ def raise_server_error(response)
74
+ raise AdapterConnectionError,
75
+ I18n.t("rubycode.errors.adapter.server_error",
76
+ code: response.code,
77
+ message: response.message)
78
+ end
79
+
80
+ def raise_http_error(response)
81
+ raise AdapterError,
82
+ I18n.t("rubycode.errors.adapter.http_error",
83
+ code: response.code,
84
+ message: response.message)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module Adapters
5
+ module Concerns
6
+ # HTTP client with retry logic for adapters
7
+ module HttpClient
8
+ private
9
+
10
+ def send_request_with_retry(uri, request)
11
+ attempt = 0
12
+
13
+ begin
14
+ attempt += 1
15
+ send_request(uri, request)
16
+ rescue AdapterTimeoutError, AdapterConnectionError => e
17
+ unless attempt <= @config.max_retries
18
+ raise AdapterRetryExhaustedError,
19
+ I18n.t("rubycode.errors.adapter.retry_failed",
20
+ max_retries: @config.max_retries,
21
+ error: e.message)
22
+ end
23
+
24
+ delay = @config.retry_base_delay * (2**(attempt - 1))
25
+ display_retry_status(attempt, @config.max_retries, delay, e)
26
+ sleep(delay)
27
+ retry
28
+ end
29
+ end
30
+
31
+ def display_retry_status(attempt, max_retries, delay, error)
32
+ puts Views::AgentLoop::RetryStatus.build(
33
+ attempt: attempt,
34
+ max_retries: max_retries,
35
+ delay: delay,
36
+ error: error.message
37
+ )
38
+ end
39
+
40
+ def send_request(uri, request)
41
+ response = perform_http_request(uri, request)
42
+ handle_response(response)
43
+ rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
44
+ handle_network_error(e, uri)
45
+ rescue JSON::ParserError => e
46
+ raise AdapterError, I18n.t("rubycode.errors.adapter.invalid_json", error: e.message)
47
+ rescue StandardError => e
48
+ raise AdapterError, I18n.t("rubycode.errors.adapter.unexpected_error", error: e.message)
49
+ end
50
+
51
+ def perform_http_request(uri, request)
52
+ Net::HTTP.start(
53
+ uri.hostname,
54
+ uri.port,
55
+ use_ssl: use_ssl?(uri),
56
+ read_timeout: @config.http_read_timeout,
57
+ open_timeout: @config.http_open_timeout
58
+ ) { |http| http.request(request) }
59
+ end
60
+
61
+ def use_ssl?(uri)
62
+ uri.scheme == "https"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module RubyCode
7
+ module Adapters
8
+ # DeepSeek adapter for cloud LLM integration (OpenAI-compatible API)
9
+ # DeepSeek provides powerful reasoning models with competitive pricing
10
+ class Deepseek < Base
11
+ API_ENDPOINT = "https://api.deepseek.com/v1/chat/completions"
12
+
13
+ def initialize(config)
14
+ super
15
+ validate_api_key!
16
+ end
17
+
18
+ private
19
+
20
+ def adapter_name
21
+ "DeepSeek"
22
+ end
23
+
24
+ def api_endpoint
25
+ API_ENDPOINT
26
+ end
27
+
28
+ def build_payload(messages, system, tools)
29
+ # OpenAI-compatible format: system message goes in messages array
30
+ formatted_messages = messages.dup
31
+
32
+ formatted_messages.unshift({ role: "system", content: system }) if system
33
+
34
+ payload = {
35
+ model: @config.model,
36
+ messages: formatted_messages,
37
+ temperature: 0.7
38
+ }
39
+
40
+ # DeepSeek supports function calling format
41
+ payload[:tools] = tools if tools
42
+
43
+ payload
44
+ end
45
+
46
+ def build_request(uri, payload)
47
+ request = Net::HTTP::Post.new(uri)
48
+ request["Content-Type"] = "application/json"
49
+ request["Authorization"] = "Bearer #{api_key}"
50
+ request.body = payload.to_json
51
+ request
52
+ end
53
+
54
+ def convert_response(deepseek_response)
55
+ # DeepSeek uses OpenAI-compatible format
56
+ # Format: { choices: [{ message: { role, content, tool_calls } }] }
57
+ # Our format: { "message" => { "content" => ..., "tool_calls" => [...] } }
58
+
59
+ choice = deepseek_response["choices"]&.first
60
+ raise AdapterError, I18n.t("rubycode.errors.adapter.no_choices") unless choice
61
+
62
+ message = choice["message"]
63
+ raise AdapterError, I18n.t("rubycode.errors.adapter.no_message") unless message
64
+
65
+ {
66
+ "message" => {
67
+ "content" => message["content"] || "",
68
+ "tool_calls" => format_tool_calls(message["tool_calls"])
69
+ }
70
+ }
71
+ end
72
+
73
+ def format_tool_calls(tool_calls)
74
+ return [] unless tool_calls
75
+
76
+ tool_calls.map do |tool_call|
77
+ {
78
+ "function" => {
79
+ "name" => tool_call.dig("function", "name"),
80
+ "arguments" => tool_call.dig("function", "arguments")
81
+ }
82
+ }
83
+ end
84
+ end
85
+
86
+ def extract_tokens(response_body)
87
+ usage = response_body["usage"] || {}
88
+
89
+ TokenCounter.new(
90
+ input: usage["prompt_tokens"],
91
+ output: usage["completion_tokens"],
92
+ thinking: usage["reasoning_tokens"] # DeepSeek specific
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module RubyCode
7
+ module Adapters
8
+ # Google Gemini adapter for cloud LLM integration
9
+ # Gemini provides powerful multimodal models with long context windows
10
+ class Gemini < Base
11
+ API_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models"
12
+
13
+ def initialize(config)
14
+ super
15
+ validate_api_key!
16
+ end
17
+
18
+ private
19
+
20
+ def adapter_name
21
+ "Gemini"
22
+ end
23
+
24
+ def api_endpoint
25
+ # Gemini embeds API key in URL
26
+ "https://generativelanguage.googleapis.com/v1beta/models/#{@config.model}:generateContent?key=#{api_key}"
27
+ end
28
+
29
+ def build_payload(messages, system, tools)
30
+ # Convert to Gemini format
31
+ contents = messages.map do |msg|
32
+ {
33
+ role: msg[:role] == "assistant" ? "model" : "user",
34
+ parts: [{ text: msg[:content] }]
35
+ }
36
+ end
37
+
38
+ payload = { contents: contents }
39
+
40
+ # Add system instruction if provided
41
+ payload[:systemInstruction] = { parts: [{ text: system }] } if system
42
+
43
+ # Add tools if provided
44
+ payload[:tools] = convert_tools_to_gemini_format(tools) if tools
45
+
46
+ payload
47
+ end
48
+
49
+ def convert_tools_to_gemini_format(tools)
50
+ return nil unless tools
51
+
52
+ [{
53
+ functionDeclarations: tools.map do |tool|
54
+ {
55
+ name: tool.dig(:function, :name),
56
+ description: tool.dig(:function, :description),
57
+ parameters: tool.dig(:function, :parameters)
58
+ }
59
+ end
60
+ }]
61
+ end
62
+
63
+ def build_request(uri, payload)
64
+ request = Net::HTTP::Post.new(uri)
65
+ request["Content-Type"] = "application/json"
66
+ request.body = payload.to_json
67
+ request
68
+ end
69
+
70
+ def convert_response(gemini_response)
71
+ # Gemini format: { candidates: [{ content: { parts: [...], role: "model" }, finishReason: "STOP" }] }
72
+ # Our format: { "message" => { "content" => ..., "tool_calls" => [...] } }
73
+
74
+ parts = extract_parts_from_response(gemini_response)
75
+
76
+ {
77
+ "message" => {
78
+ "content" => extract_text_content(parts),
79
+ "tool_calls" => extract_tool_calls(parts)
80
+ }
81
+ }
82
+ end
83
+
84
+ def extract_parts_from_response(gemini_response)
85
+ candidate = gemini_response["candidates"]&.first
86
+ raise AdapterError, I18n.t("rubycode.errors.adapter.no_choices") unless candidate
87
+
88
+ content_obj = candidate["content"]
89
+ raise AdapterError, I18n.t("rubycode.errors.adapter.no_message") unless content_obj
90
+
91
+ content_obj["parts"] || []
92
+ end
93
+
94
+ def extract_text_content(parts)
95
+ parts
96
+ .select { |p| p["text"] }
97
+ .map { |p| p["text"] }
98
+ .join("\n")
99
+ end
100
+
101
+ def extract_tool_calls(parts)
102
+ parts
103
+ .select { |p| p["functionCall"] }
104
+ .map { |p| convert_function_call(p["functionCall"]) }
105
+ end
106
+
107
+ def convert_function_call(function_call)
108
+ {
109
+ "function" => {
110
+ "name" => function_call["name"],
111
+ "arguments" => function_call["args"] || {}
112
+ }
113
+ }
114
+ end
115
+
116
+ def extract_tokens(response_body)
117
+ usage = response_body["usageMetadata"] || {}
118
+ candidates = usage["candidatesTokenCount"] || 0
119
+ thoughts = usage["thoughtsTokenCount"] || 0
120
+
121
+ TokenCounter.new(
122
+ input: usage["promptTokenCount"],
123
+ output: candidates + thoughts, # Must sum both
124
+ thinking: thoughts
125
+ )
126
+ end
127
+
128
+ def sanitize_url(uri)
129
+ uri.to_s.gsub(/key=[^&]*/, "key=***")
130
+ end
131
+ end
132
+ end
133
+ end