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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module RubyCode
|
|
9
|
+
module SearchProviders
|
|
10
|
+
# Brave Search API provider
|
|
11
|
+
# https://brave.com/search/api/
|
|
12
|
+
class BraveSearch < Base
|
|
13
|
+
API_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
|
|
14
|
+
|
|
15
|
+
def initialize(api_key: nil, config: nil)
|
|
16
|
+
super(config: config, api_key: api_key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def provider_name
|
|
22
|
+
"BraveSearch"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def requires_api_key?
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_request(query, max_results)
|
|
30
|
+
uri = URI(API_ENDPOINT).tap do |u|
|
|
31
|
+
u.query = URI.encode_www_form(
|
|
32
|
+
q: query,
|
|
33
|
+
count: max_results
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
request = Net::HTTP::Get.new(uri)
|
|
38
|
+
request["Accept"] = "application/json"
|
|
39
|
+
request["X-Subscription-Token"] = api_key
|
|
40
|
+
|
|
41
|
+
{ uri: uri, request: request }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse_results(json_body, _max_results)
|
|
45
|
+
data = JSON.parse(json_body)
|
|
46
|
+
results = data.dig("web", "results") || []
|
|
47
|
+
|
|
48
|
+
results.map do |result|
|
|
49
|
+
{
|
|
50
|
+
title: result["title"],
|
|
51
|
+
url: result["url"],
|
|
52
|
+
snippet: result["description"] || ""
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
rescue JSON::ParserError => e
|
|
56
|
+
raise SearchProviderError, "Failed to parse Brave Search response: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module SearchProviders
|
|
5
|
+
module Concerns
|
|
6
|
+
# Debug output for search providers
|
|
7
|
+
module Debugging
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def debug_search_request(query, max_results, uri_or_request)
|
|
11
|
+
url = if uri_or_request.is_a?(URI)
|
|
12
|
+
uri_or_request.to_s
|
|
13
|
+
elsif uri_or_request.is_a?(Hash)
|
|
14
|
+
uri_or_request[:uri].to_s
|
|
15
|
+
else
|
|
16
|
+
"Unknown"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
puts Views::SearchProvider::DebugRequest.build(
|
|
20
|
+
provider_name: provider_name,
|
|
21
|
+
query: query,
|
|
22
|
+
max_results: max_results,
|
|
23
|
+
url: url
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def debug_search_response(response)
|
|
28
|
+
puts Views::SearchProvider::DebugResponse.build(
|
|
29
|
+
provider_name: provider_name,
|
|
30
|
+
status_code: response.code,
|
|
31
|
+
body_preview: response.body[0...500] # First 500 chars
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module SearchProviders
|
|
5
|
+
module Concerns
|
|
6
|
+
# Error handling for search providers
|
|
7
|
+
module ErrorHandling
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def handle_network_error(error, uri)
|
|
11
|
+
error_key = network_error_key(error)
|
|
12
|
+
error_params = build_network_error_params(error, uri)
|
|
13
|
+
raise SearchProviderError, I18n.t("rubycode.errors.search_provider.#{error_key}", error_params)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def network_error_key(error)
|
|
17
|
+
case error
|
|
18
|
+
when Net::ReadTimeout then :read_timeout
|
|
19
|
+
when Net::OpenTimeout then :open_timeout
|
|
20
|
+
when Errno::ECONNREFUSED then :connection_refused
|
|
21
|
+
when Errno::ETIMEDOUT then :connection_timeout
|
|
22
|
+
when SocketError then :host_unreachable
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_network_error_params(error, uri)
|
|
27
|
+
base_params = { provider: provider_name, error: error.message }
|
|
28
|
+
case error
|
|
29
|
+
when Net::ReadTimeout
|
|
30
|
+
base_params.merge(timeout: http_read_timeout)
|
|
31
|
+
when Net::OpenTimeout
|
|
32
|
+
base_params.merge(timeout: http_open_timeout)
|
|
33
|
+
when Errno::ECONNREFUSED, Errno::ETIMEDOUT
|
|
34
|
+
base_params.merge(uri: uri)
|
|
35
|
+
when SocketError
|
|
36
|
+
base_params.merge(hostname: uri.hostname)
|
|
37
|
+
else
|
|
38
|
+
base_params
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handle_http_error(response)
|
|
43
|
+
code = response.code.to_i
|
|
44
|
+
return response if code.between?(200, 299)
|
|
45
|
+
|
|
46
|
+
error_key = http_error_key(code)
|
|
47
|
+
raise SearchProviderError,
|
|
48
|
+
I18n.t("rubycode.errors.search_provider.#{error_key}",
|
|
49
|
+
code: response.code,
|
|
50
|
+
provider: provider_name)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def http_error_key(code)
|
|
54
|
+
case code
|
|
55
|
+
when 401, 403 then :auth_failed
|
|
56
|
+
when 429 then :rate_limited
|
|
57
|
+
when 500..599 then :server_error
|
|
58
|
+
else :http_error
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module RubyCode
|
|
6
|
+
module SearchProviders
|
|
7
|
+
module Concerns
|
|
8
|
+
# HTTP client for search providers
|
|
9
|
+
module HttpClient
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def make_http_request(uri_or_request)
|
|
13
|
+
if uri_or_request.is_a?(URI)
|
|
14
|
+
make_http_request_from_uri(uri_or_request)
|
|
15
|
+
elsif uri_or_request.is_a?(Hash) && uri_or_request[:uri] && uri_or_request[:request]
|
|
16
|
+
make_http_request_with_custom_request(uri_or_request[:uri], uri_or_request[:request])
|
|
17
|
+
else
|
|
18
|
+
raise ArgumentError, "Expected URI or {uri:, request:} hash"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def make_http_request_from_uri(uri)
|
|
23
|
+
Net::HTTP.start(
|
|
24
|
+
uri.host,
|
|
25
|
+
uri.port,
|
|
26
|
+
use_ssl: uri.scheme == "https",
|
|
27
|
+
read_timeout: http_read_timeout,
|
|
28
|
+
open_timeout: http_open_timeout
|
|
29
|
+
) do |http|
|
|
30
|
+
request = Net::HTTP::Get.new(uri)
|
|
31
|
+
request["Accept"] = "application/json"
|
|
32
|
+
request["User-Agent"] = "RubyCode/#{RubyCode::VERSION}"
|
|
33
|
+
http.request(request)
|
|
34
|
+
end
|
|
35
|
+
rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
|
|
36
|
+
handle_network_error(e, uri)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
raise SearchProviderError, "HTTP request failed: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def make_http_request_with_custom_request(uri, request)
|
|
42
|
+
Net::HTTP.start(
|
|
43
|
+
uri.host,
|
|
44
|
+
uri.port,
|
|
45
|
+
use_ssl: uri.scheme == "https",
|
|
46
|
+
read_timeout: http_read_timeout,
|
|
47
|
+
open_timeout: http_open_timeout
|
|
48
|
+
) do |http|
|
|
49
|
+
http.request(request)
|
|
50
|
+
end
|
|
51
|
+
rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
|
|
52
|
+
handle_network_error(e, uri)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
raise SearchProviderError, "HTTP request failed: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def http_read_timeout
|
|
58
|
+
@config&.http_read_timeout || 10
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def http_open_timeout
|
|
62
|
+
@config&.http_open_timeout || 10
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module RubyCode
|
|
9
|
+
module SearchProviders
|
|
10
|
+
# DuckDuckGo Instant Answer API provider (FREE)
|
|
11
|
+
# https://duckduckgo.com/api
|
|
12
|
+
# Note: Returns instant answers/summaries, not full web search results
|
|
13
|
+
class DuckduckgoInstant < Base
|
|
14
|
+
API_ENDPOINT = "https://api.duckduckgo.com/"
|
|
15
|
+
|
|
16
|
+
def initialize(config: nil)
|
|
17
|
+
super(config: config, api_key: nil)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def provider_name
|
|
23
|
+
"DuckDuckGo"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_request(query, _max_results)
|
|
27
|
+
URI(API_ENDPOINT).tap do |uri|
|
|
28
|
+
uri.query = URI.encode_www_form(
|
|
29
|
+
q: query,
|
|
30
|
+
format: "json",
|
|
31
|
+
no_html: 1,
|
|
32
|
+
skip_disambig: 1
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_results(json_body, max_results)
|
|
38
|
+
data = JSON.parse(json_body)
|
|
39
|
+
results = []
|
|
40
|
+
|
|
41
|
+
add_abstract_result(results, data)
|
|
42
|
+
add_related_topics(results, data, max_results)
|
|
43
|
+
add_other_results(results, data, max_results) if results.empty?
|
|
44
|
+
|
|
45
|
+
results
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
raise SearchProviderError, "Failed to parse DuckDuckGo response: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_abstract_result(results, data)
|
|
51
|
+
return unless data["Abstract"] && !data["Abstract"].empty?
|
|
52
|
+
|
|
53
|
+
results << {
|
|
54
|
+
title: data["Heading"] || "Summary",
|
|
55
|
+
url: data["AbstractURL"] || "",
|
|
56
|
+
snippet: data["Abstract"]
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_related_topics(results, data, max_results)
|
|
61
|
+
related = data["RelatedTopics"] || []
|
|
62
|
+
related.first(max_results - results.length).each do |topic|
|
|
63
|
+
next if skip_topic?(topic)
|
|
64
|
+
|
|
65
|
+
results << build_topic_result(topic)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def skip_topic?(topic)
|
|
70
|
+
topic["Topics"] # Skip categories
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_topic_result(topic)
|
|
74
|
+
{
|
|
75
|
+
title: extract_topic_title(topic),
|
|
76
|
+
url: topic["FirstURL"] || "",
|
|
77
|
+
snippet: topic["Text"] || ""
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_topic_title(topic)
|
|
82
|
+
topic["Text"]&.split(" - ")&.first || "Related"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def add_other_results(results, data, max_results)
|
|
86
|
+
return unless data["Results"]
|
|
87
|
+
|
|
88
|
+
data["Results"].first(max_results).each do |result|
|
|
89
|
+
results << {
|
|
90
|
+
title: result["Text"] || "Result",
|
|
91
|
+
url: result["FirstURL"] || "",
|
|
92
|
+
snippet: result["Text"] || ""
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module RubyCode
|
|
9
|
+
module SearchProviders
|
|
10
|
+
# Exa.ai search provider - AI-native search engine optimized for LLMs
|
|
11
|
+
# https://exa.ai
|
|
12
|
+
# Uses MCP (Model Context Protocol) endpoint
|
|
13
|
+
class ExaAi < Base
|
|
14
|
+
MCP_ENDPOINT = "https://mcp.exa.ai/mcp"
|
|
15
|
+
TIMEOUT = 25 # seconds
|
|
16
|
+
|
|
17
|
+
def initialize(api_key: nil, config: nil)
|
|
18
|
+
super(config: config, api_key: api_key || ENV.fetch("EXA_API_KEY", nil))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def provider_name
|
|
24
|
+
"Exa.ai"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def requires_api_key?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_request(query, max_results)
|
|
32
|
+
uri = URI(MCP_ENDPOINT)
|
|
33
|
+
request = Net::HTTP::Post.new(uri)
|
|
34
|
+
request["Content-Type"] = "application/json"
|
|
35
|
+
request["Accept"] = "application/json, text/event-stream"
|
|
36
|
+
request["User-Agent"] = "RubyCode/#{RubyCode::VERSION}"
|
|
37
|
+
|
|
38
|
+
request.body = build_mcp_payload(query, max_results).to_json
|
|
39
|
+
|
|
40
|
+
{ uri: uri, request: request }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_mcp_payload(query, max_results)
|
|
44
|
+
{
|
|
45
|
+
jsonrpc: "2.0",
|
|
46
|
+
id: 1,
|
|
47
|
+
method: "tools/call",
|
|
48
|
+
params: {
|
|
49
|
+
name: "web_search_exa",
|
|
50
|
+
arguments: {
|
|
51
|
+
query: query,
|
|
52
|
+
type: "auto",
|
|
53
|
+
numResults: max_results,
|
|
54
|
+
livecrawl: "fallback",
|
|
55
|
+
contextMaxCharacters: 10_000
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse_results(body, _max_results)
|
|
62
|
+
results = []
|
|
63
|
+
|
|
64
|
+
# Parse SSE (Server-Sent Events) response
|
|
65
|
+
body.split("\n").each do |line|
|
|
66
|
+
next unless line.start_with?("data: ")
|
|
67
|
+
|
|
68
|
+
process_sse_line(line, results)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
results
|
|
72
|
+
rescue JSON::ParserError => e
|
|
73
|
+
raise SearchProviderError, "Failed to parse Exa.ai response: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def process_sse_line(line, results)
|
|
77
|
+
data = JSON.parse(line[6..]) # Skip "data: " prefix
|
|
78
|
+
return unless data["result"]&.dig("content")
|
|
79
|
+
|
|
80
|
+
data["result"]["content"].each do |item|
|
|
81
|
+
next unless item["type"] == "text"
|
|
82
|
+
|
|
83
|
+
results.concat(parse_markdown_results(item["text"]))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_markdown_results(text)
|
|
88
|
+
results = []
|
|
89
|
+
current_result = {}
|
|
90
|
+
collecting_text = false
|
|
91
|
+
|
|
92
|
+
text.split("\n").each do |line|
|
|
93
|
+
line = line.strip
|
|
94
|
+
next if line.empty?
|
|
95
|
+
|
|
96
|
+
result = process_markdown_line(line, current_result, results, collecting_text)
|
|
97
|
+
collecting_text = result[:collecting]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
finalize_result(current_result, results)
|
|
101
|
+
cleanup_snippets(results)
|
|
102
|
+
|
|
103
|
+
results
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def process_markdown_line(line, current_result, results, collecting_text)
|
|
107
|
+
return handle_title_line(line, current_result, results) if line.start_with?("Title:")
|
|
108
|
+
return handle_url_line(line, current_result, collecting_text) if line.start_with?("URL:")
|
|
109
|
+
return handle_text_line(line, current_result) if line.start_with?("Text:")
|
|
110
|
+
|
|
111
|
+
handle_content_line(line, current_result, collecting_text)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def handle_title_line(line, current_result, results)
|
|
115
|
+
save_current_result(current_result, results)
|
|
116
|
+
start_new_result(line, current_result)
|
|
117
|
+
{ collecting: false }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def handle_url_line(line, current_result, collecting_text)
|
|
121
|
+
current_result[:url] = line.sub("URL:", "").strip if current_result[:title]
|
|
122
|
+
{ collecting: collecting_text }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def handle_text_line(line, current_result)
|
|
126
|
+
current_result[:snippet] = line.sub("Text:", "").strip if current_result[:title]
|
|
127
|
+
{ collecting: true }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def handle_content_line(line, current_result, collecting_text)
|
|
131
|
+
return { collecting: collecting_text } unless collecting_text && current_result[:title]
|
|
132
|
+
|
|
133
|
+
append_text_if_valid(line, current_result)
|
|
134
|
+
{ collecting: collecting_text }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def save_current_result(current_result, results)
|
|
138
|
+
return unless current_result[:title] && current_result[:url] && !current_result[:url].empty?
|
|
139
|
+
|
|
140
|
+
results << current_result
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def start_new_result(line, current_result)
|
|
144
|
+
current_result.replace(
|
|
145
|
+
title: line.sub("Title:", "").strip,
|
|
146
|
+
url: "",
|
|
147
|
+
snippet: ""
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def append_text_if_valid(line, current_result)
|
|
152
|
+
return if current_result[:snippet].length >= 500
|
|
153
|
+
|
|
154
|
+
break_keywords = ["Title:", "Author:", "Published Date:", "URL:"]
|
|
155
|
+
return if break_keywords.any? { |kw| line.start_with?(kw) }
|
|
156
|
+
|
|
157
|
+
current_result[:snippet] = "#{current_result[:snippet]} #{line}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def finalize_result(current_result, results)
|
|
161
|
+
save_current_result(current_result, results)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def cleanup_snippets(results)
|
|
165
|
+
results.each do |result|
|
|
166
|
+
result[:snippet] = result[:snippet].strip[0..300] # Limit to 300 chars
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module SearchProviders
|
|
5
|
+
# Multi-provider search with automatic fallback
|
|
6
|
+
class MultiProvider
|
|
7
|
+
attr_reader :last_used_provider
|
|
8
|
+
|
|
9
|
+
def initialize(providers: [])
|
|
10
|
+
@providers = providers
|
|
11
|
+
@last_used_provider = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def search(query, max_results: 5)
|
|
15
|
+
last_error = nil
|
|
16
|
+
|
|
17
|
+
@providers.each do |provider|
|
|
18
|
+
results = provider.search(query, max_results: max_results)
|
|
19
|
+
if results && !results.empty?
|
|
20
|
+
@last_used_provider = provider.class.name.split("::").last
|
|
21
|
+
return results
|
|
22
|
+
end
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
last_error = e
|
|
25
|
+
# Continue to next provider
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# If all providers failed, raise the last error
|
|
29
|
+
raise last_error if last_error
|
|
30
|
+
|
|
31
|
+
# If no error but no results, return empty array
|
|
32
|
+
@last_used_provider = nil
|
|
33
|
+
[]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Add a provider to the list
|
|
37
|
+
def add_provider(provider)
|
|
38
|
+
@providers << provider
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get list of available providers
|
|
42
|
+
def provider_names
|
|
43
|
+
@providers.map { |p| p.class.name.split("::").last }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
# Tracks token usage across LLM requests
|
|
5
|
+
class TokenCounter
|
|
6
|
+
attr_reader :input, :output, :cached, :cache_creation, :thinking
|
|
7
|
+
|
|
8
|
+
def initialize(input: 0, output: 0, cached: 0, cache_creation: 0, thinking: 0)
|
|
9
|
+
@input = input || 0
|
|
10
|
+
@output = output || 0
|
|
11
|
+
@cached = cached || 0
|
|
12
|
+
@cache_creation = cache_creation || 0
|
|
13
|
+
@thinking = thinking || 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def total
|
|
17
|
+
input + output + thinking
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
input_tokens: input,
|
|
23
|
+
output_tokens: output,
|
|
24
|
+
cached_tokens: cached,
|
|
25
|
+
cache_creation_tokens: cache_creation,
|
|
26
|
+
thinking_tokens: thinking,
|
|
27
|
+
total_tokens: total
|
|
28
|
+
}.compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def +(other)
|
|
32
|
+
TokenCounter.new(
|
|
33
|
+
input: input + other.input,
|
|
34
|
+
output: output + other.output,
|
|
35
|
+
cached: cached + other.cached,
|
|
36
|
+
cache_creation: cache_creation + other.cache_creation,
|
|
37
|
+
thinking: thinking + other.thinking
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/rubycode/tools/bash.rb
CHANGED
|
@@ -44,19 +44,49 @@ module RubyCode
|
|
|
44
44
|
|
|
45
45
|
def execute_command(command)
|
|
46
46
|
Dir.chdir(root_path) do
|
|
47
|
-
|
|
48
|
-
exit_code = $CHILD_STATUS.exitstatus
|
|
47
|
+
require "open3"
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
output, exit_code = run_command_with_streaming(command)
|
|
50
|
+
raise CommandExecutionError, build_error_message(exit_code, output) unless exit_code.zero?
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
stdout: truncate_output(output),
|
|
54
|
-
stderr: "",
|
|
55
|
-
exit_code: exit_code
|
|
56
|
-
)
|
|
52
|
+
build_command_result(output, exit_code)
|
|
57
53
|
end
|
|
58
54
|
end
|
|
59
55
|
|
|
56
|
+
def run_command_with_streaming(command)
|
|
57
|
+
output_lines = []
|
|
58
|
+
exit_code = 0
|
|
59
|
+
|
|
60
|
+
Open3.popen2e(command) do |stdin, stdout_err, wait_thr|
|
|
61
|
+
stdin.close # Close stdin to prevent interactive prompts
|
|
62
|
+
output_lines = stream_output(stdout_err)
|
|
63
|
+
exit_code = wait_thr.value.exitstatus
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
[output_lines.join, exit_code]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stream_output(stdout_err)
|
|
70
|
+
output_lines = []
|
|
71
|
+
stdout_err.each_line do |line|
|
|
72
|
+
print line # Show output in real-time
|
|
73
|
+
output_lines << line
|
|
74
|
+
end
|
|
75
|
+
output_lines
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_error_message(exit_code, output)
|
|
79
|
+
"Command failed with exit code #{exit_code}:\n#{output}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_command_result(output, exit_code)
|
|
83
|
+
CommandResult.new(
|
|
84
|
+
stdout: truncate_output(output),
|
|
85
|
+
stderr: "",
|
|
86
|
+
exit_code: exit_code
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
60
90
|
def truncate_output(output)
|
|
61
91
|
lines = output.split("\n")
|
|
62
92
|
return output if lines.length <= 200
|