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.
- checksums.yaml +4 -4
- data/.env.example +38 -0
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +52 -0
- data/README.md +101 -23
- data/USAGE.md +1 -23
- data/config/locales/en.yml +208 -1
- data/config/system_prompt.md +6 -1
- data/config/tools/bash.json +1 -1
- data/config/tools/fetch.json +22 -0
- data/config/tools/websearch.json +22 -0
- data/docs/images/demo.png +0 -0
- data/lib/rubycode/adapters/base.rb +92 -2
- data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
- data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
- data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
- data/lib/rubycode/adapters/deepseek.rb +97 -0
- data/lib/rubycode/adapters/gemini.rb +133 -0
- data/lib/rubycode/adapters/ollama.rb +114 -82
- data/lib/rubycode/adapters/openai.rb +97 -0
- data/lib/rubycode/adapters/openrouter.rb +102 -0
- data/lib/rubycode/agent_loop.rb +110 -18
- data/lib/rubycode/client/approval_handler.rb +14 -0
- data/lib/rubycode/client/display_formatter.rb +18 -10
- data/lib/rubycode/client/response_handler.rb +4 -23
- data/lib/rubycode/client.rb +9 -0
- data/lib/rubycode/config_manager.rb +81 -0
- data/lib/rubycode/configuration.rb +21 -10
- data/lib/rubycode/database.rb +19 -0
- data/lib/rubycode/errors.rb +12 -0
- data/lib/rubycode/models/api_key.rb +118 -0
- data/lib/rubycode/models/memory.rb +84 -10
- data/lib/rubycode/models.rb +1 -0
- data/lib/rubycode/pricing.rb +59 -0
- data/lib/rubycode/search_providers/base.rb +66 -0
- data/lib/rubycode/search_providers/brave_search.rb +60 -0
- data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
- data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
- data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
- data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
- data/lib/rubycode/search_providers/exa_ai.rb +171 -0
- data/lib/rubycode/search_providers/multi_provider.rb +47 -0
- data/lib/rubycode/token_counter.rb +41 -0
- data/lib/rubycode/tools/bash.rb +38 -8
- data/lib/rubycode/tools/fetch.rb +120 -0
- data/lib/rubycode/tools/web_search.rb +122 -0
- data/lib/rubycode/tools.rb +5 -1
- data/lib/rubycode/value_objects.rb +8 -4
- data/lib/rubycode/version.rb +1 -1
- data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
- data/lib/rubycode/views/adapter/debug_request.rb +33 -0
- data/lib/rubycode/views/adapter/debug_response.rb +31 -0
- data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
- data/lib/rubycode/views/agent_loop.rb +2 -0
- data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
- data/lib/rubycode/views/cli/config_saved.rb +17 -0
- data/lib/rubycode/views/cli/configuration_table.rb +3 -3
- data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
- data/lib/rubycode/views/cli/restart_message.rb +17 -0
- data/lib/rubycode/views/cli/setup_title.rb +17 -0
- data/lib/rubycode/views/cli.rb +5 -0
- data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
- data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
- data/lib/rubycode/views/formatter.rb +2 -0
- data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
- data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
- data/lib/rubycode/views/web_search_approval.rb +29 -0
- data/lib/rubycode/views.rb +5 -0
- data/lib/rubycode.rb +10 -0
- data/rubycode_cli.rb +228 -32
- metadata +81 -1
|
@@ -12,7 +12,9 @@ module RubyCode
|
|
|
12
12
|
"read" => ["[READ]", "file_path"],
|
|
13
13
|
"search" => ["[SEARCH]", "pattern"],
|
|
14
14
|
"write" => ["[WRITE]", "file_path"],
|
|
15
|
-
"update" => ["[UPDATE]", "file_path"]
|
|
15
|
+
"update" => ["[UPDATE]", "file_path"],
|
|
16
|
+
"web_search" => ["[WEB SEARCH]", "query"],
|
|
17
|
+
"fetch" => ["[FETCH]", "url"]
|
|
16
18
|
}.freeze
|
|
17
19
|
|
|
18
20
|
def initialize(config:)
|
|
@@ -21,17 +23,12 @@ module RubyCode
|
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def display_tool_info(tool_name, arguments)
|
|
24
|
-
|
|
25
|
-
puts Views::Formatter::DebugToolInfo.build(tool_name: tool_name, arguments: arguments)
|
|
26
|
-
else
|
|
27
|
-
display_minimal_tool_info(tool_name, arguments)
|
|
28
|
-
end
|
|
26
|
+
display_minimal_tool_info(tool_name, arguments)
|
|
29
27
|
end
|
|
30
28
|
|
|
31
|
-
def display_result(result)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
puts Views::Formatter::ToolResult.build(result: result)
|
|
29
|
+
def display_result(result, tool_name: nil)
|
|
30
|
+
# Show summaries for web tools
|
|
31
|
+
display_tool_summary(result, tool_name) if %w[web_search fetch].include?(tool_name)
|
|
35
32
|
end
|
|
36
33
|
|
|
37
34
|
def display_info(message)
|
|
@@ -44,6 +41,17 @@ module RubyCode
|
|
|
44
41
|
|
|
45
42
|
private
|
|
46
43
|
|
|
44
|
+
def display_tool_summary(result, tool_name)
|
|
45
|
+
return unless result.is_a?(ToolResult)
|
|
46
|
+
|
|
47
|
+
case tool_name
|
|
48
|
+
when "web_search"
|
|
49
|
+
puts Views::Formatter::WebSearchSummary.build(result: result)
|
|
50
|
+
when "fetch"
|
|
51
|
+
puts Views::Formatter::FetchSummary.build(result: result)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
47
55
|
def display_minimal_tool_info(tool_name, arguments)
|
|
48
56
|
return unless TOOL_LABELS.key?(tool_name)
|
|
49
57
|
|
|
@@ -21,15 +21,8 @@ module RubyCode
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def handle_empty_tool_calls(content, iteration, total_tool_calls)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return nil
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
unless @config.debug
|
|
30
|
-
puts Views::ResponseHandler::CompleteMessage.build(iteration: iteration,
|
|
31
|
-
total_tool_calls: total_tool_calls)
|
|
32
|
-
end
|
|
24
|
+
puts Views::ResponseHandler::CompleteMessage.build(iteration: iteration,
|
|
25
|
+
total_tool_calls: total_tool_calls)
|
|
33
26
|
content
|
|
34
27
|
end
|
|
35
28
|
|
|
@@ -42,22 +35,10 @@ module RubyCode
|
|
|
42
35
|
end
|
|
43
36
|
|
|
44
37
|
def finalize_response(done_result, iteration, total_tool_calls)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
total_tool_calls: total_tool_calls + 1)
|
|
48
|
-
end
|
|
38
|
+
puts Views::ResponseHandler::AgentFinished.build(iteration: iteration,
|
|
39
|
+
total_tool_calls: total_tool_calls + 1)
|
|
49
40
|
done_result
|
|
50
41
|
end
|
|
51
|
-
|
|
52
|
-
private
|
|
53
|
-
|
|
54
|
-
def inject_tool_reminder(iteration)
|
|
55
|
-
puts Views::ResponseHandler::ToolInjectionWarning.build(iteration: iteration) unless @config.debug
|
|
56
|
-
@memory.add_message(
|
|
57
|
-
role: "user",
|
|
58
|
-
content: "You MUST call a tool. Do not respond with text. Call search, read, bash, or done tool now."
|
|
59
|
-
)
|
|
60
|
-
end
|
|
61
42
|
end
|
|
62
43
|
end
|
|
63
44
|
end
|
data/lib/rubycode/client.rb
CHANGED
|
@@ -11,6 +11,7 @@ module RubyCode
|
|
|
11
11
|
@config = RubyCode.config
|
|
12
12
|
@adapter = build_adapter
|
|
13
13
|
@memory = Memory.new
|
|
14
|
+
@memory.clear # Clear memory at start of each session to prevent payload size issues
|
|
14
15
|
@read_files = Set.new
|
|
15
16
|
@tty_prompt = tty_prompt
|
|
16
17
|
end
|
|
@@ -39,6 +40,14 @@ module RubyCode
|
|
|
39
40
|
case @config.adapter
|
|
40
41
|
when :ollama
|
|
41
42
|
Adapters::Ollama.new(@config)
|
|
43
|
+
when :openrouter
|
|
44
|
+
Adapters::Openrouter.new(@config)
|
|
45
|
+
when :deepseek
|
|
46
|
+
Adapters::Deepseek.new(@config)
|
|
47
|
+
when :gemini
|
|
48
|
+
Adapters::Gemini.new(@config)
|
|
49
|
+
when :openai
|
|
50
|
+
Adapters::Openai.new(@config)
|
|
42
51
|
else
|
|
43
52
|
raise I18n.t("rubycode.errors.unknown_adapter")
|
|
44
53
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
# Manages configuration persistence to ~/.rubycode/config.yml
|
|
8
|
+
class ConfigManager
|
|
9
|
+
CONFIG_DIR = File.join(Dir.home, ".rubycode")
|
|
10
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Load configuration from file
|
|
14
|
+
# Returns hash with symbolized keys or nil if file doesn't exist
|
|
15
|
+
def load
|
|
16
|
+
return nil unless exists?
|
|
17
|
+
|
|
18
|
+
yaml_content = File.read(CONFIG_FILE)
|
|
19
|
+
config_hash = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
|
|
20
|
+
|
|
21
|
+
# Symbolize keys
|
|
22
|
+
symbolize_keys(config_hash)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
warn "Warning: Failed to load config from #{CONFIG_FILE}: #{e.message}"
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Save configuration to file
|
|
29
|
+
# @param config_hash [Hash] Configuration hash to save
|
|
30
|
+
def save(config_hash)
|
|
31
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
32
|
+
|
|
33
|
+
File.write(CONFIG_FILE, config_hash.to_yaml)
|
|
34
|
+
true
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
warn "Warning: Failed to save config to #{CONFIG_FILE}: #{e.message}"
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if config file exists
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def exists?
|
|
43
|
+
File.exist?(CONFIG_FILE)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Get default configuration for a given adapter
|
|
47
|
+
# @param adapter [Symbol] The adapter name (:ollama, :groq)
|
|
48
|
+
# @return [Hash] Default configuration
|
|
49
|
+
def defaults_for_adapter(adapter)
|
|
50
|
+
case adapter
|
|
51
|
+
when :groq
|
|
52
|
+
{
|
|
53
|
+
adapter: :groq,
|
|
54
|
+
model: "llama-3.1-8b-instant",
|
|
55
|
+
url: "https://api.groq.com/openai/v1/chat/completions"
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
# Default to Ollama for unknown adapters
|
|
59
|
+
{
|
|
60
|
+
adapter: :ollama,
|
|
61
|
+
model: "deepseek-r1:8b",
|
|
62
|
+
url: "http://localhost:11434"
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Recursively symbolize hash keys
|
|
70
|
+
def symbolize_keys(hash)
|
|
71
|
+
return hash unless hash.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
hash.transform_keys do |key|
|
|
74
|
+
key.to_sym
|
|
75
|
+
rescue StandardError
|
|
76
|
+
key
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -3,26 +3,37 @@
|
|
|
3
3
|
module RubyCode
|
|
4
4
|
# Configuration class for Rubycode settings
|
|
5
5
|
class Configuration
|
|
6
|
-
attr_accessor :adapter, :url, :model, :root_path,
|
|
7
|
-
:http_read_timeout, :http_open_timeout, :max_retries, :retry_base_delay
|
|
6
|
+
attr_accessor :adapter, :url, :model, :root_path,
|
|
7
|
+
:http_read_timeout, :http_open_timeout, :max_retries, :retry_base_delay,
|
|
8
|
+
:adapter_request_delay, :memory_window, :prune_tool_results
|
|
8
9
|
|
|
9
10
|
def initialize
|
|
10
11
|
@adapter = :ollama
|
|
11
|
-
@url = "
|
|
12
|
-
@model = "
|
|
12
|
+
@url = "https://api.ollama.com"
|
|
13
|
+
@model = "qwen3-coder:480b-cloud"
|
|
13
14
|
@root_path = Dir.pwd
|
|
14
|
-
@debug = false # Set to true to see JSON requests/responses
|
|
15
|
-
|
|
16
|
-
# WORKAROUND for models that don't follow tool-calling instructions
|
|
17
|
-
# When enabled, injects reminder messages if model generates text instead of calling tools
|
|
18
|
-
# Enabled by default as most models need this nudge
|
|
19
|
-
@enable_tool_injection_workaround = true
|
|
20
15
|
|
|
21
16
|
# HTTP timeout and retry configuration
|
|
22
17
|
@http_read_timeout = 120 # 2 minutes for LLM inference
|
|
23
18
|
@http_open_timeout = 10 # 10 seconds for connection
|
|
24
19
|
@max_retries = 3 # Number of retries (4 total attempts)
|
|
25
20
|
@retry_base_delay = 2.0 # Base delay for exponential backoff
|
|
21
|
+
|
|
22
|
+
# Rate limiting configuration
|
|
23
|
+
@adapter_request_delay = 1.5 # Delay between consecutive requests (seconds)
|
|
24
|
+
|
|
25
|
+
# Memory optimization configuration
|
|
26
|
+
@memory_window = 10 # Keep last N messages for context
|
|
27
|
+
@prune_tool_results = true # Replace old tool results with placeholder
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Load configuration from a hash
|
|
31
|
+
# @param hash [Hash] Configuration hash with symbolized keys
|
|
32
|
+
def load_from_hash(hash)
|
|
33
|
+
@adapter = hash[:adapter] if hash.key?(:adapter)
|
|
34
|
+
@model = hash[:model] if hash.key?(:model)
|
|
35
|
+
@url = hash[:url] if hash.key?(:url)
|
|
36
|
+
@root_path = hash[:root_path] if hash.key?(:root_path)
|
|
26
37
|
end
|
|
27
38
|
end
|
|
28
39
|
end
|
data/lib/rubycode/database.rb
CHANGED
|
@@ -35,6 +35,7 @@ module RubyCode
|
|
|
35
35
|
|
|
36
36
|
def run_migrations
|
|
37
37
|
create_messages_table
|
|
38
|
+
create_api_keys_table
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def create_messages_table
|
|
@@ -44,6 +45,24 @@ module RubyCode
|
|
|
44
45
|
String :content, null: false, text: true
|
|
45
46
|
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
|
|
46
47
|
end
|
|
48
|
+
|
|
49
|
+
# Add tool_calls column if it doesn't exist (migration for existing databases)
|
|
50
|
+
return if @db.schema(:messages).any? { |col| col[0] == :tool_calls }
|
|
51
|
+
|
|
52
|
+
@db.alter_table(:messages) do
|
|
53
|
+
add_column :tool_calls, String, text: true, null: true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def create_api_keys_table
|
|
58
|
+
@db.create_table?(:api_keys) do
|
|
59
|
+
primary_key :id
|
|
60
|
+
String :adapter, null: false, unique: true
|
|
61
|
+
String :encrypted_key, null: false, text: true
|
|
62
|
+
String :iv, null: false # Initialization vector for encryption
|
|
63
|
+
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
|
|
64
|
+
DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP
|
|
65
|
+
end
|
|
47
66
|
end
|
|
48
67
|
end
|
|
49
68
|
end
|
data/lib/rubycode/errors.rb
CHANGED
|
@@ -19,6 +19,15 @@ module RubyCode
|
|
|
19
19
|
# Raised when command execution fails
|
|
20
20
|
class CommandExecutionError < ToolError; end
|
|
21
21
|
|
|
22
|
+
# Base class for all network-related errors
|
|
23
|
+
class NetworkError < ToolError; end
|
|
24
|
+
|
|
25
|
+
# Raised when HTTP request fails
|
|
26
|
+
class HTTPError < NetworkError; end
|
|
27
|
+
|
|
28
|
+
# Raised when URL is invalid
|
|
29
|
+
class URLError < NetworkError; end
|
|
30
|
+
|
|
22
31
|
# Base class for all adapter-related errors
|
|
23
32
|
class AdapterError < Error; end
|
|
24
33
|
|
|
@@ -30,4 +39,7 @@ module RubyCode
|
|
|
30
39
|
|
|
31
40
|
# Raised when all retry attempts are exhausted
|
|
32
41
|
class AdapterRetryExhaustedError < AdapterError; end
|
|
42
|
+
|
|
43
|
+
# Base class for all search provider-related errors
|
|
44
|
+
class SearchProviderError < Error; end
|
|
33
45
|
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Models
|
|
8
|
+
# Manages encrypted API keys for LLM providers
|
|
9
|
+
class ApiKey < Base
|
|
10
|
+
class << self
|
|
11
|
+
def table_name
|
|
12
|
+
:api_keys
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Save an API key for a specific adapter
|
|
16
|
+
# @param adapter [Symbol] The adapter name (:ollama, :groq, etc.)
|
|
17
|
+
# @param api_key [String] The plaintext API key
|
|
18
|
+
def save_key(adapter:, api_key:)
|
|
19
|
+
encrypted_data = encrypt(api_key)
|
|
20
|
+
existing = dataset.where(adapter: adapter.to_s).first
|
|
21
|
+
|
|
22
|
+
if existing
|
|
23
|
+
update_existing_key(adapter, encrypted_data)
|
|
24
|
+
else
|
|
25
|
+
insert_new_key(adapter, encrypted_data)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update_existing_key(adapter, encrypted_data)
|
|
30
|
+
dataset.where(adapter: adapter.to_s).update(
|
|
31
|
+
encrypted_key: encrypted_data[:encrypted],
|
|
32
|
+
iv: encrypted_data[:iv],
|
|
33
|
+
updated_at: Time.now
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def insert_new_key(adapter, encrypted_data)
|
|
38
|
+
dataset.insert(
|
|
39
|
+
adapter: adapter.to_s,
|
|
40
|
+
encrypted_key: encrypted_data[:encrypted],
|
|
41
|
+
iv: encrypted_data[:iv]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Retrieve and decrypt an API key for a specific adapter
|
|
46
|
+
# @param adapter [Symbol] The adapter name
|
|
47
|
+
# @return [String, nil] The decrypted API key or nil if not found
|
|
48
|
+
def get_key(adapter:)
|
|
49
|
+
row = dataset.where(adapter: adapter.to_s).first
|
|
50
|
+
return nil unless row
|
|
51
|
+
|
|
52
|
+
decrypt(
|
|
53
|
+
encrypted: row[:encrypted_key],
|
|
54
|
+
init_vector: row[:iv]
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Delete an API key for a specific adapter
|
|
59
|
+
# @param adapter [Symbol] The adapter name
|
|
60
|
+
def delete_key(adapter:)
|
|
61
|
+
dataset.where(adapter: adapter.to_s).delete
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if an API key exists for a specific adapter
|
|
65
|
+
# @param adapter [Symbol] The adapter name
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def key_exists?(adapter:)
|
|
68
|
+
dataset.where(adapter: adapter.to_s).any?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Encrypt a plaintext API key
|
|
74
|
+
# @param plaintext [String] The API key to encrypt
|
|
75
|
+
# @return [Hash] Contains :encrypted and :iv
|
|
76
|
+
def encrypt(plaintext)
|
|
77
|
+
cipher = OpenSSL::Cipher.new("AES-256-CBC")
|
|
78
|
+
cipher.encrypt
|
|
79
|
+
cipher.key = encryption_key
|
|
80
|
+
iv = cipher.random_iv
|
|
81
|
+
|
|
82
|
+
encrypted = cipher.update(plaintext) + cipher.final
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
encrypted: Base64.strict_encode64(encrypted),
|
|
86
|
+
iv: Base64.strict_encode64(iv)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Decrypt an encrypted API key
|
|
91
|
+
# @param encrypted [String] Base64-encoded encrypted data
|
|
92
|
+
# @param init_vector [String] Base64-encoded initialization vector
|
|
93
|
+
# @return [String] The decrypted API key
|
|
94
|
+
def decrypt(encrypted:, init_vector:)
|
|
95
|
+
cipher = OpenSSL::Cipher.new("AES-256-CBC")
|
|
96
|
+
cipher.decrypt
|
|
97
|
+
cipher.key = encryption_key
|
|
98
|
+
cipher.iv = Base64.strict_decode64(init_vector)
|
|
99
|
+
|
|
100
|
+
cipher.update(Base64.strict_decode64(encrypted)) + cipher.final
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Generate a consistent encryption key based on user's environment
|
|
104
|
+
# This creates a per-user encryption key
|
|
105
|
+
# @return [String] 32-byte encryption key
|
|
106
|
+
def encryption_key
|
|
107
|
+
# Use a combination of home directory and a salt to generate a consistent key
|
|
108
|
+
# This provides per-user encryption without requiring password entry
|
|
109
|
+
salt = "rubycode_api_key_encryption_v1"
|
|
110
|
+
key_base = "#{Dir.home}#{salt}"
|
|
111
|
+
|
|
112
|
+
# Use SHA-256 to generate a 32-byte key
|
|
113
|
+
OpenSSL::Digest::SHA256.digest(key_base)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -10,11 +10,11 @@ module RubyCode
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
# Accept either Message objects or keyword arguments for backwards compatibility
|
|
13
|
-
def add_message(message = nil, role: nil, content: nil)
|
|
13
|
+
def add_message(message = nil, role: nil, content: nil, tool_calls: nil)
|
|
14
14
|
if message.is_a?(Message)
|
|
15
|
-
insert_message(message.role, message.content)
|
|
15
|
+
insert_message(message.role, message.content, message.tool_calls)
|
|
16
16
|
elsif role && content
|
|
17
|
-
insert_message(role, content)
|
|
17
|
+
insert_message(role, content, tool_calls)
|
|
18
18
|
else
|
|
19
19
|
raise ArgumentError, "Must provide either a Message object or role: and content: keyword arguments"
|
|
20
20
|
end
|
|
@@ -22,12 +22,60 @@ module RubyCode
|
|
|
22
22
|
|
|
23
23
|
def messages
|
|
24
24
|
db[:messages].order(:id).map do |row|
|
|
25
|
-
Message.new(
|
|
25
|
+
Message.new(
|
|
26
|
+
role: row[:role],
|
|
27
|
+
content: row[:content],
|
|
28
|
+
tool_calls: deserialize_tool_calls(row[:tool_calls])
|
|
29
|
+
)
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
|
|
29
|
-
def to_llm_format
|
|
30
|
-
messages
|
|
33
|
+
def to_llm_format(window_size: 10, prune_tool_results: true)
|
|
34
|
+
all_messages = messages
|
|
35
|
+
return all_messages.map(&:to_h) if all_messages.length <= window_size
|
|
36
|
+
|
|
37
|
+
build_windowed_format(all_messages, window_size, prune_tool_results)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_windowed_format(all_messages, window_size, prune_tool_results)
|
|
41
|
+
first_msg, recent, middle = partition_messages(all_messages, window_size)
|
|
42
|
+
|
|
43
|
+
return recent.map(&:to_h) if recent.include?(first_msg)
|
|
44
|
+
|
|
45
|
+
result_messages = build_message_list(first_msg, middle, recent, prune_tool_results)
|
|
46
|
+
result_messages.map(&:to_h)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_message_list(first_msg, middle, recent, prune_tool_results)
|
|
50
|
+
return [first_msg] + recent unless should_prune_middle?(prune_tool_results, middle)
|
|
51
|
+
|
|
52
|
+
pruned_middle = prune_tool_results_from_middle(middle)
|
|
53
|
+
[first_msg] + pruned_middle + recent
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def partition_messages(all_messages, window_size)
|
|
57
|
+
first_msg = all_messages.first
|
|
58
|
+
recent = all_messages.last(window_size)
|
|
59
|
+
middle = all_messages[1..-(window_size + 1)] || []
|
|
60
|
+
[first_msg, recent, middle]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def should_prune_middle?(prune_tool_results, middle)
|
|
64
|
+
prune_tool_results && middle.any? { |msg| tool_result_message?(msg) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tool_result_message?(message)
|
|
68
|
+
message.role == "user" && message.content.start_with?("Tool '")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def prune_tool_results_from_middle(middle)
|
|
72
|
+
middle.map do |msg|
|
|
73
|
+
if tool_result_message?(msg)
|
|
74
|
+
Message.new(role: msg.role, content: "[Tool result cleared]")
|
|
75
|
+
else
|
|
76
|
+
msg
|
|
77
|
+
end
|
|
78
|
+
end
|
|
31
79
|
end
|
|
32
80
|
|
|
33
81
|
def clear
|
|
@@ -38,20 +86,46 @@ module RubyCode
|
|
|
38
86
|
row = self.class.latest.where(role: "user").first
|
|
39
87
|
return nil unless row
|
|
40
88
|
|
|
41
|
-
Message.new(
|
|
89
|
+
Message.new(
|
|
90
|
+
role: row[:role],
|
|
91
|
+
content: row[:content],
|
|
92
|
+
tool_calls: deserialize_tool_calls(row[:tool_calls])
|
|
93
|
+
)
|
|
42
94
|
end
|
|
43
95
|
|
|
44
96
|
def last_assistant_message
|
|
45
97
|
row = self.class.latest.where(role: "assistant").first
|
|
46
98
|
return nil unless row
|
|
47
99
|
|
|
48
|
-
Message.new(
|
|
100
|
+
Message.new(
|
|
101
|
+
role: row[:role],
|
|
102
|
+
content: row[:content],
|
|
103
|
+
tool_calls: deserialize_tool_calls(row[:tool_calls])
|
|
104
|
+
)
|
|
49
105
|
end
|
|
50
106
|
|
|
51
107
|
private
|
|
52
108
|
|
|
53
|
-
def insert_message(role, content)
|
|
54
|
-
db[:messages].insert(
|
|
109
|
+
def insert_message(role, content, tool_calls = nil)
|
|
110
|
+
db[:messages].insert(
|
|
111
|
+
role: role,
|
|
112
|
+
content: content,
|
|
113
|
+
tool_calls: serialize_tool_calls(tool_calls)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def serialize_tool_calls(tool_calls)
|
|
118
|
+
return nil if tool_calls.nil? || tool_calls.empty?
|
|
119
|
+
|
|
120
|
+
JSON.generate(tool_calls)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def deserialize_tool_calls(tool_calls_json)
|
|
124
|
+
return nil if tool_calls_json.nil? || tool_calls_json.empty?
|
|
125
|
+
|
|
126
|
+
JSON.parse(tool_calls_json)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
nil
|
|
55
129
|
end
|
|
56
130
|
end
|
|
57
131
|
end
|
data/lib/rubycode/models.rb
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Calculates cost estimates based on provider pricing
|
|
5
|
+
module Pricing
|
|
6
|
+
# Prices per 1M tokens (as of March 2026)
|
|
7
|
+
RATES = {
|
|
8
|
+
gemini: {
|
|
9
|
+
"gemini-2.5-flash" => { input: 0.0375, output: 0.15, cached: 0.01 },
|
|
10
|
+
"gemini-2.5-pro" => { input: 1.25, output: 5.00, cached: 0.31 },
|
|
11
|
+
"gemini-3-flash-preview" => { input: 0.0375, output: 0.15, cached: 0.01 }
|
|
12
|
+
},
|
|
13
|
+
openai: {
|
|
14
|
+
"gpt-4o" => { input: 2.50, output: 10.00, cached: 1.25 },
|
|
15
|
+
"gpt-4o-mini" => { input: 0.15, output: 0.60, cached: 0.075 },
|
|
16
|
+
"o1" => { input: 15.00, output: 60.00 }
|
|
17
|
+
},
|
|
18
|
+
deepseek: {
|
|
19
|
+
"deepseek-chat" => { input: 0.14, output: 0.28, cached: 0.014 },
|
|
20
|
+
"deepseek-reasoner" => { input: 0.55, output: 2.19, cached: 0.014 }
|
|
21
|
+
},
|
|
22
|
+
openrouter: {
|
|
23
|
+
"anthropic/claude-sonnet-4.5" => { input: 3.00, output: 15.00, cached: 0.30 },
|
|
24
|
+
"anthropic/claude-opus-4.6" => { input: 15.00, output: 75.00, cached: 1.50 }
|
|
25
|
+
},
|
|
26
|
+
ollama: {
|
|
27
|
+
"default" => { input: 0.0, output: 0.0 }
|
|
28
|
+
}
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def self.calculate_cost(adapter:, model:, tokens:)
|
|
32
|
+
rates = find_rates(adapter, model)
|
|
33
|
+
return 0.0 unless rates
|
|
34
|
+
|
|
35
|
+
input_cost = calculate_token_cost(tokens.input, rates[:input])
|
|
36
|
+
output_cost = calculate_token_cost(tokens.output, rates[:output])
|
|
37
|
+
cached_cost = calculate_token_cost(tokens.cached, rates[:cached] || 0)
|
|
38
|
+
|
|
39
|
+
input_cost + output_cost - cached_cost # Cached reduces cost
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.find_rates(adapter, model)
|
|
43
|
+
RATES.dig(adapter.to_sym, model) || RATES.dig(adapter.to_sym, "default")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.calculate_token_cost(token_count, rate_per_million)
|
|
47
|
+
(token_count / 1_000_000.0) * rate_per_million
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.format_cost(cost_usd)
|
|
51
|
+
if cost_usd < 0.01
|
|
52
|
+
cents = (cost_usd * 100).round(4)
|
|
53
|
+
"$#{cents}¢"
|
|
54
|
+
else
|
|
55
|
+
"$#{cost_usd.round(4)}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "concerns/http_client"
|
|
4
|
+
require_relative "concerns/error_handling"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module SearchProviders
|
|
8
|
+
# Base class for search providers
|
|
9
|
+
class Base
|
|
10
|
+
include Concerns::HttpClient
|
|
11
|
+
include Concerns::ErrorHandling
|
|
12
|
+
|
|
13
|
+
def initialize(config: nil, api_key: nil)
|
|
14
|
+
@config = config
|
|
15
|
+
@api_key = api_key
|
|
16
|
+
validate_api_key! if requires_api_key?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def search(query, max_results: 5)
|
|
20
|
+
uri_or_request = build_request(query, max_results)
|
|
21
|
+
|
|
22
|
+
debug_search_request(query, max_results, uri_or_request) if debug_enabled?
|
|
23
|
+
|
|
24
|
+
response = make_http_request(uri_or_request)
|
|
25
|
+
|
|
26
|
+
debug_search_response(response) if debug_enabled?
|
|
27
|
+
|
|
28
|
+
parse_results(response.body, max_results)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Abstract methods to be implemented by subclasses
|
|
34
|
+
def provider_name
|
|
35
|
+
raise NotImplementedError, "Subclass must define provider_name"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_request(_query, _max_results)
|
|
39
|
+
raise NotImplementedError, "Subclass must define build_request"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse_results(_body, _max_results)
|
|
43
|
+
raise NotImplementedError, "Subclass must define parse_results"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Concrete shared methods
|
|
47
|
+
def requires_api_key?
|
|
48
|
+
# Override in subclass if API key is required
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
attr_reader :api_key
|
|
53
|
+
|
|
54
|
+
def validate_api_key!
|
|
55
|
+
return if api_key && !api_key.empty?
|
|
56
|
+
|
|
57
|
+
raise SearchProviderError, I18n.t("rubycode.errors.search_provider.api_key_missing",
|
|
58
|
+
provider: provider_name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def debug_enabled?
|
|
62
|
+
@config&.debug || false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|