rubyn-code 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- metadata +53 -1
|
@@ -8,68 +8,62 @@ module RubynCode
|
|
|
8
8
|
module Tools
|
|
9
9
|
class WebFetch < Base
|
|
10
10
|
TOOL_NAME = 'web_fetch'
|
|
11
|
-
DESCRIPTION = 'Fetch the content of a web page and return it as text.
|
|
11
|
+
DESCRIPTION = 'Fetch the content of a web page and return it as text. ' \
|
|
12
|
+
'Useful for reading documentation, READMEs, or API docs.'
|
|
12
13
|
PARAMETERS = {
|
|
13
|
-
url: {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
url: {
|
|
15
|
+
type: :string, required: true,
|
|
16
|
+
description: 'The URL to fetch (must start with http:// or https://)'
|
|
17
|
+
},
|
|
18
|
+
max_length: {
|
|
19
|
+
type: :integer, required: false, default: 10_000,
|
|
20
|
+
description: 'Maximum number of characters to return (default: 10000)'
|
|
21
|
+
}
|
|
16
22
|
}.freeze
|
|
17
23
|
RISK_LEVEL = :external
|
|
18
24
|
REQUIRES_CONFIRMATION = true
|
|
19
25
|
|
|
26
|
+
MAX_REDIRECTS = 5
|
|
27
|
+
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
28
|
+
|
|
20
29
|
def execute(url:, max_length: 10_000)
|
|
21
30
|
validate_url!(url)
|
|
22
|
-
max_length =
|
|
31
|
+
max_length = max_length.to_i.clamp(500, 100_000)
|
|
23
32
|
|
|
24
33
|
response = fetch_page(url)
|
|
25
34
|
text = html_to_text(response.body)
|
|
26
35
|
text = collapse_whitespace(text)
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
"Fetched #{url} but no readable text content was found."
|
|
30
|
-
else
|
|
31
|
-
header = "Content from: #{url}\n#{'=' * 60}\n\n"
|
|
32
|
-
available = max_length - header.length
|
|
33
|
-
content = if text.length > available
|
|
34
|
-
"#{text[0,
|
|
35
|
-
available]}\n\n... [truncated at #{max_length} characters]"
|
|
36
|
-
else
|
|
37
|
-
text
|
|
38
|
-
end
|
|
39
|
-
"#{header}#{content}"
|
|
40
|
-
end
|
|
37
|
+
format_fetched_content(url, text, max_length)
|
|
41
38
|
end
|
|
42
39
|
|
|
43
40
|
private
|
|
44
41
|
|
|
42
|
+
def format_fetched_content(url, text, max_length)
|
|
43
|
+
return "Fetched #{url} but no readable text content was found." if text.strip.empty?
|
|
44
|
+
|
|
45
|
+
header = "Content from: #{url}\n#{'=' * 60}\n\n"
|
|
46
|
+
available = max_length - header.length
|
|
47
|
+
content = text.length > available ? truncate_content(text, available, max_length) : text
|
|
48
|
+
"#{header}#{content}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def truncate_content(text, available, max_length)
|
|
52
|
+
"#{text[0, available]}\n\n... [truncated at #{max_length} characters]"
|
|
53
|
+
end
|
|
54
|
+
|
|
45
55
|
def validate_url!(url)
|
|
46
56
|
return if url.match?(%r{\Ahttps?://}i)
|
|
47
57
|
|
|
48
|
-
raise Error,
|
|
58
|
+
raise Error,
|
|
59
|
+
"Invalid URL: must start with http:// or https:// — got: #{url}"
|
|
49
60
|
end
|
|
50
61
|
|
|
51
|
-
MAX_REDIRECTS = 5
|
|
52
|
-
|
|
53
62
|
def fetch_page(url, redirects: 0)
|
|
54
|
-
conn =
|
|
55
|
-
f.options.timeout = 30
|
|
56
|
-
f.options.open_timeout = 10
|
|
57
|
-
f.headers['User-Agent'] = 'Mozilla/5.0 (compatible; RubynCode/1.0)'
|
|
58
|
-
f.headers['Accept'] = 'text/html,application/xhtml+xml,text/plain,*/*'
|
|
59
|
-
end
|
|
60
|
-
|
|
63
|
+
conn = build_connection
|
|
61
64
|
response = conn.get(url)
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
raise Error, "Too many redirects fetching #{url}" if redirects >= MAX_REDIRECTS
|
|
65
|
-
|
|
66
|
-
location = response.headers['location']
|
|
67
|
-
raise Error, "Redirect with no Location header from #{url}" unless location
|
|
68
|
-
|
|
69
|
-
location = URI.join(url, location).to_s unless location.start_with?('http')
|
|
70
|
-
return fetch_page(location, redirects: redirects + 1)
|
|
71
|
-
end
|
|
72
|
-
|
|
66
|
+
return handle_redirect(url, response, redirects) if REDIRECT_STATUSES.include?(response.status)
|
|
73
67
|
raise Error, "HTTP #{response.status} fetching #{url}" unless response.success?
|
|
74
68
|
|
|
75
69
|
response
|
|
@@ -81,24 +75,48 @@ module RubynCode
|
|
|
81
75
|
raise Error, "Request failed for #{url}: #{e.message}"
|
|
82
76
|
end
|
|
83
77
|
|
|
78
|
+
def build_connection
|
|
79
|
+
Faraday.new do |f|
|
|
80
|
+
f.options.timeout = 30
|
|
81
|
+
f.options.open_timeout = 10
|
|
82
|
+
f.headers['User-Agent'] = 'Mozilla/5.0 (compatible; RubynCode/1.0)'
|
|
83
|
+
f.headers['Accept'] = 'text/html,application/xhtml+xml,text/plain,*/*'
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def handle_redirect(url, response, redirects)
|
|
88
|
+
raise Error, "Too many redirects fetching #{url}" if redirects >= MAX_REDIRECTS
|
|
89
|
+
|
|
90
|
+
location = response.headers['location']
|
|
91
|
+
raise Error, "Redirect with no Location header from #{url}" unless location
|
|
92
|
+
|
|
93
|
+
location = URI.join(url, location).to_s unless location.start_with?('http')
|
|
94
|
+
fetch_page(location, redirects: redirects + 1)
|
|
95
|
+
end
|
|
96
|
+
|
|
84
97
|
def html_to_text(html)
|
|
85
98
|
return '' if html.nil? || html.empty?
|
|
86
99
|
|
|
87
100
|
text = html.dup
|
|
101
|
+
strip_scripts_and_styles!(text)
|
|
102
|
+
convert_block_elements!(text)
|
|
103
|
+
text.gsub!(/<[^>]*>/, '')
|
|
104
|
+
decode_html_entities!(text)
|
|
105
|
+
text
|
|
106
|
+
end
|
|
88
107
|
|
|
89
|
-
|
|
108
|
+
def strip_scripts_and_styles!(text)
|
|
90
109
|
text.gsub!(%r{<script[^>]*>.*?</script>}mi, '')
|
|
91
110
|
text.gsub!(%r{<style[^>]*>.*?</style>}mi, '')
|
|
111
|
+
end
|
|
92
112
|
|
|
93
|
-
|
|
113
|
+
def convert_block_elements!(text)
|
|
94
114
|
text.gsub!(%r{<br\s*/?>}i, "\n")
|
|
95
115
|
text.gsub!(%r{</(p|div|h[1-6]|li|tr|blockquote|pre)>}i, "\n")
|
|
96
116
|
text.gsub!(/<(p|div|h[1-6]|li|tr|blockquote|pre)[^>]*>/i, "\n")
|
|
117
|
+
end
|
|
97
118
|
|
|
98
|
-
|
|
99
|
-
text.gsub!(/<[^>]*>/, '')
|
|
100
|
-
|
|
101
|
-
# Decode common HTML entities
|
|
119
|
+
def decode_html_entities!(text)
|
|
102
120
|
text.gsub!('&', '&')
|
|
103
121
|
text.gsub!('<', '<')
|
|
104
122
|
text.gsub!('>', '>')
|
|
@@ -106,12 +124,9 @@ module RubynCode
|
|
|
106
124
|
text.gsub!(''', "'")
|
|
107
125
|
text.gsub!(' ', ' ')
|
|
108
126
|
text.gsub!(/&#(\d+);/) { [::Regexp.last_match(1).to_i].pack('U') }
|
|
109
|
-
|
|
110
|
-
text
|
|
111
127
|
end
|
|
112
128
|
|
|
113
129
|
def collapse_whitespace(text)
|
|
114
|
-
# Collapse runs of spaces/tabs on each line, then collapse 3+ newlines into 2
|
|
115
130
|
text.gsub(/[^\S\n]+/, ' ')
|
|
116
131
|
.gsub(/\n{3,}/, "\n\n")
|
|
117
132
|
.strip
|
|
@@ -14,12 +14,15 @@ module RubynCode
|
|
|
14
14
|
DESCRIPTION = 'Search the web for information. Returns search results with titles, URLs, and snippets.'
|
|
15
15
|
PARAMETERS = {
|
|
16
16
|
query: { type: :string, required: true, description: 'The search query string' },
|
|
17
|
-
num_results: {
|
|
17
|
+
num_results: {
|
|
18
|
+
type: :integer, required: false, default: 5,
|
|
19
|
+
description: 'Number of results (default: 5)'
|
|
20
|
+
}
|
|
18
21
|
}.freeze
|
|
19
22
|
RISK_LEVEL = :external
|
|
20
23
|
REQUIRES_CONFIRMATION = true
|
|
21
24
|
|
|
22
|
-
# Adapter registry
|
|
25
|
+
# Adapter registry -- add new providers here
|
|
23
26
|
ADAPTERS = {
|
|
24
27
|
'duckduckgo' => :search_duckduckgo,
|
|
25
28
|
'brave' => :search_brave,
|
|
@@ -29,7 +32,7 @@ module RubynCode
|
|
|
29
32
|
}.freeze
|
|
30
33
|
|
|
31
34
|
def execute(query:, num_results: 5)
|
|
32
|
-
num_results =
|
|
35
|
+
num_results = num_results.to_i.clamp(1, 20)
|
|
33
36
|
provider = detect_provider
|
|
34
37
|
|
|
35
38
|
results = send(ADAPTERS[provider], query, num_results)
|
|
@@ -45,24 +48,24 @@ module RubynCode
|
|
|
45
48
|
|
|
46
49
|
private
|
|
47
50
|
|
|
48
|
-
# Pick the best available provider based on env vars
|
|
49
51
|
def detect_provider
|
|
50
|
-
return 'tavily'
|
|
51
|
-
return 'brave'
|
|
52
|
+
return 'tavily' if ENV['TAVILY_API_KEY']
|
|
53
|
+
return 'brave' if ENV['BRAVE_API_KEY']
|
|
52
54
|
return 'serpapi' if ENV['SERPAPI_API_KEY']
|
|
53
55
|
return 'google' if ENV['GOOGLE_SEARCH_API_KEY'] && ENV['GOOGLE_SEARCH_CX']
|
|
54
56
|
|
|
55
57
|
'duckduckgo'
|
|
56
58
|
end
|
|
57
59
|
|
|
58
|
-
#
|
|
60
|
+
# --- DuckDuckGo (no API key, free) ---
|
|
59
61
|
|
|
60
62
|
def search_duckduckgo(query, num_results)
|
|
61
63
|
encoded = CGI.escape(query)
|
|
64
|
+
url = "https://lite.duckduckgo.com/lite/?q=#{encoded}"
|
|
62
65
|
stdout, _, status = safe_capture3(
|
|
63
66
|
'curl', '-sL', '--max-time', '15',
|
|
64
67
|
'-H', 'User-Agent: Mozilla/5.0 (compatible; RubynCode/1.0)',
|
|
65
|
-
|
|
68
|
+
url
|
|
66
69
|
)
|
|
67
70
|
return [] unless status.success?
|
|
68
71
|
|
|
@@ -70,11 +73,20 @@ module RubynCode
|
|
|
70
73
|
end
|
|
71
74
|
|
|
72
75
|
def parse_duckduckgo(html, max)
|
|
73
|
-
|
|
74
|
-
links = html.scan(%r{<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>(.*?)</a>}i)
|
|
75
|
-
links = html.scan(%r{<a[^>]+href="(https?://(?!lite\.duckduckgo)[^"]+)"[^>]*>(.*?)</a>}i) if links.empty?
|
|
76
|
+
links = extract_ddg_links(html)
|
|
76
77
|
snippets = html.scan(%r{<td[^>]*class="result-snippet"[^>]*>(.*?)</td>}im)
|
|
78
|
+
build_ddg_results(links, snippets, max)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_ddg_links(html)
|
|
82
|
+
links = html.scan(%r{<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>(.*?)</a>}i)
|
|
83
|
+
return links unless links.empty?
|
|
84
|
+
|
|
85
|
+
html.scan(%r{<a[^>]+href="(https?://(?!lite\.duckduckgo)[^"]+)"[^>]*>(.*?)</a>}i)
|
|
86
|
+
end
|
|
77
87
|
|
|
88
|
+
def build_ddg_results(links, snippets, max) # rubocop:disable Metrics/AbcSize -- HTML parsing with filtering
|
|
89
|
+
results = []
|
|
78
90
|
links.each_with_index do |match, idx|
|
|
79
91
|
break if results.length >= max
|
|
80
92
|
|
|
@@ -85,14 +97,21 @@ module RubynCode
|
|
|
85
97
|
snippet = snippets[idx] ? strip_html(snippets[idx][0]).strip : ''
|
|
86
98
|
results << { title: title, url: url, snippet: snippet }
|
|
87
99
|
end
|
|
88
|
-
|
|
89
100
|
results
|
|
90
101
|
end
|
|
91
102
|
|
|
92
|
-
#
|
|
103
|
+
# --- Brave Search (free tier: 2000 queries/mo) ---
|
|
93
104
|
|
|
94
105
|
def search_brave(query, num_results)
|
|
95
|
-
resp =
|
|
106
|
+
resp = brave_request(query, num_results)
|
|
107
|
+
data = JSON.parse(resp.body)
|
|
108
|
+
(data.dig('web', 'results') || []).map do |r|
|
|
109
|
+
{ title: r['title'], url: r['url'], snippet: r['description'] || '' }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def brave_request(query, num_results)
|
|
114
|
+
Faraday.get('https://api.search.brave.com/res/v1/web/search') do |req|
|
|
96
115
|
req.params['q'] = query
|
|
97
116
|
req.params['count'] = num_results
|
|
98
117
|
req.headers['Accept'] = 'application/json'
|
|
@@ -100,72 +119,71 @@ module RubynCode
|
|
|
100
119
|
req.headers['X-Subscription-Token'] = ENV.fetch('BRAVE_API_KEY', nil)
|
|
101
120
|
req.options.timeout = 15
|
|
102
121
|
end
|
|
103
|
-
|
|
104
|
-
data = JSON.parse(resp.body)
|
|
105
|
-
(data.dig('web', 'results') || []).map do |r|
|
|
106
|
-
{ title: r['title'], url: r['url'], snippet: r['description'] || '' }
|
|
107
|
-
end
|
|
108
122
|
end
|
|
109
123
|
|
|
110
|
-
#
|
|
124
|
+
# --- Tavily (built for AI agents, free tier: 1000 queries/mo) ---
|
|
111
125
|
|
|
112
126
|
def search_tavily(query, num_results)
|
|
113
|
-
resp =
|
|
114
|
-
req.headers['Content-Type'] = 'application/json'
|
|
115
|
-
req.body = JSON.generate({
|
|
116
|
-
api_key: ENV.fetch('TAVILY_API_KEY', nil),
|
|
117
|
-
query: query,
|
|
118
|
-
max_results: num_results,
|
|
119
|
-
include_answer: true
|
|
120
|
-
})
|
|
121
|
-
req.options.timeout = 15
|
|
122
|
-
end
|
|
123
|
-
|
|
127
|
+
resp = tavily_request(query, num_results)
|
|
124
128
|
data = JSON.parse(resp.body)
|
|
125
129
|
results = (data['results'] || []).map do |r|
|
|
126
130
|
{ title: r['title'], url: r['url'], snippet: r['content'] || '' }
|
|
127
131
|
end
|
|
128
|
-
|
|
129
|
-
# Tavily provides a direct answer — prepend it
|
|
130
|
-
results.unshift({ title: 'AI Answer', url: '', snippet: data['answer'] }) if data['answer']
|
|
131
|
-
|
|
132
|
+
results.unshift(title: 'AI Answer', url: '', snippet: data['answer']) if data['answer']
|
|
132
133
|
results
|
|
133
134
|
end
|
|
134
135
|
|
|
135
|
-
|
|
136
|
+
def tavily_request(query, num_results)
|
|
137
|
+
Faraday.post('https://api.tavily.com/search') do |req|
|
|
138
|
+
req.headers['Content-Type'] = 'application/json'
|
|
139
|
+
req.body = JSON.generate(
|
|
140
|
+
api_key: ENV.fetch('TAVILY_API_KEY', nil),
|
|
141
|
+
query: query, max_results: num_results, include_answer: true
|
|
142
|
+
)
|
|
143
|
+
req.options.timeout = 15
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- SerpAPI (free tier: 100 queries/mo) ---
|
|
136
148
|
|
|
137
149
|
def search_serpapi(query, num_results)
|
|
138
|
-
resp =
|
|
150
|
+
resp = serpapi_request(query, num_results)
|
|
151
|
+
data = JSON.parse(resp.body)
|
|
152
|
+
(data['organic_results'] || []).map do |r|
|
|
153
|
+
{ title: r['title'], url: r['link'], snippet: r['snippet'] || '' }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def serpapi_request(query, num_results)
|
|
158
|
+
Faraday.get('https://serpapi.com/search.json') do |req|
|
|
139
159
|
req.params['q'] = query
|
|
140
160
|
req.params['num'] = num_results
|
|
141
161
|
req.params['api_key'] = ENV.fetch('SERPAPI_API_KEY', nil)
|
|
142
162
|
req.options.timeout = 15
|
|
143
163
|
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# --- Google Custom Search (free tier: 100 queries/day) ---
|
|
144
167
|
|
|
168
|
+
def search_google(query, num_results)
|
|
169
|
+
resp = google_request(query, num_results)
|
|
145
170
|
data = JSON.parse(resp.body)
|
|
146
|
-
(data['
|
|
171
|
+
(data['items'] || []).map do |r|
|
|
147
172
|
{ title: r['title'], url: r['link'], snippet: r['snippet'] || '' }
|
|
148
173
|
end
|
|
149
174
|
end
|
|
150
175
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def search_google(query, num_results)
|
|
154
|
-
resp = Faraday.get('https://www.googleapis.com/customsearch/v1') do |req|
|
|
176
|
+
def google_request(query, num_results)
|
|
177
|
+
Faraday.get('https://www.googleapis.com/customsearch/v1') do |req|
|
|
155
178
|
req.params['q'] = query
|
|
156
179
|
req.params['num'] = [num_results, 10].min
|
|
157
180
|
req.params['key'] = ENV.fetch('GOOGLE_SEARCH_API_KEY', nil)
|
|
158
181
|
req.params['cx'] = ENV.fetch('GOOGLE_SEARCH_CX', nil)
|
|
159
182
|
req.options.timeout = 15
|
|
160
183
|
end
|
|
161
|
-
|
|
162
|
-
data = JSON.parse(resp.body)
|
|
163
|
-
(data['items'] || []).map do |r|
|
|
164
|
-
{ title: r['title'], url: r['link'], snippet: r['snippet'] || '' }
|
|
165
|
-
end
|
|
166
184
|
end
|
|
167
185
|
|
|
168
|
-
#
|
|
186
|
+
# --- Shared ---
|
|
169
187
|
|
|
170
188
|
def strip_html(text)
|
|
171
189
|
return '' if text.nil?
|
|
@@ -16,13 +16,71 @@ module RubynCode
|
|
|
16
16
|
RISK_LEVEL = :write
|
|
17
17
|
REQUIRES_CONFIRMATION = false
|
|
18
18
|
|
|
19
|
+
PREVIEW_LINES = 15
|
|
20
|
+
|
|
19
21
|
def execute(path:, content:)
|
|
20
22
|
resolved = safe_path(path)
|
|
23
|
+
existed = File.exist?(resolved)
|
|
24
|
+
old_content = existed ? File.read(resolved) : nil
|
|
21
25
|
|
|
22
26
|
FileUtils.mkdir_p(File.dirname(resolved))
|
|
23
27
|
bytes = File.write(resolved, content)
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
format_result(path, bytes, existed, old_content, content)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def format_result(path, bytes, existed, old_content, new_content)
|
|
35
|
+
lines = []
|
|
36
|
+
|
|
37
|
+
if existed
|
|
38
|
+
lines << "Updated #{path} (#{bytes} bytes)"
|
|
39
|
+
lines << diff_preview(old_content, new_content)
|
|
40
|
+
else
|
|
41
|
+
lines << "Created #{path} (#{bytes} bytes)"
|
|
42
|
+
lines << file_preview(new_content)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
lines.compact.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def file_preview(content)
|
|
49
|
+
preview_lines = content.lines.first(PREVIEW_LINES)
|
|
50
|
+
preview = preview_lines.each_with_index.map do |line, idx|
|
|
51
|
+
" #{(idx + 1).to_s.rjust(3)}│ #{line.chomp}"
|
|
52
|
+
end.join("\n")
|
|
53
|
+
|
|
54
|
+
remaining = content.lines.count - PREVIEW_LINES
|
|
55
|
+
preview += "\n ... (#{remaining} more lines)" if remaining.positive?
|
|
56
|
+
preview
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def diff_preview(old_content, new_content)
|
|
60
|
+
old_lines = (old_content || '').lines.map(&:chomp)
|
|
61
|
+
new_lines = new_content.lines.map(&:chomp)
|
|
62
|
+
|
|
63
|
+
changes = simple_diff(old_lines, new_lines)
|
|
64
|
+
return ' (no visible changes)' if changes.empty?
|
|
65
|
+
|
|
66
|
+
changes.first(20).join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def simple_diff(old_lines, new_lines)
|
|
70
|
+
changes = []
|
|
71
|
+
max = [old_lines.length, new_lines.length].max
|
|
72
|
+
|
|
73
|
+
max.times do |idx|
|
|
74
|
+
old_line = old_lines[idx]
|
|
75
|
+
new_line = new_lines[idx]
|
|
76
|
+
|
|
77
|
+
next if old_line == new_line
|
|
78
|
+
|
|
79
|
+
changes << " - #{old_line}" if old_line
|
|
80
|
+
changes << " + #{new_line}" if new_line
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
changes
|
|
26
84
|
end
|
|
27
85
|
end
|
|
28
86
|
|
data/lib/rubyn_code/version.rb
CHANGED
data/lib/rubyn_code.rb
CHANGED
|
@@ -15,6 +15,10 @@ module RubynCode
|
|
|
15
15
|
# Infrastructure
|
|
16
16
|
autoload :Config, 'rubyn_code/config/settings'
|
|
17
17
|
|
|
18
|
+
module Config
|
|
19
|
+
autoload :ProjectProfile, 'rubyn_code/config/project_profile'
|
|
20
|
+
end
|
|
21
|
+
|
|
18
22
|
# Database
|
|
19
23
|
module DB
|
|
20
24
|
autoload :Connection, 'rubyn_code/db/connection'
|
|
@@ -32,8 +36,24 @@ module RubynCode
|
|
|
32
36
|
# LLM
|
|
33
37
|
module LLM
|
|
34
38
|
autoload :Client, 'rubyn_code/llm/client'
|
|
35
|
-
autoload :Streaming, 'rubyn_code/llm/streaming'
|
|
36
39
|
autoload :MessageBuilder, 'rubyn_code/llm/message_builder'
|
|
40
|
+
autoload :ModelRouter, 'rubyn_code/llm/model_router'
|
|
41
|
+
|
|
42
|
+
# Adapters (provider-specific implementations)
|
|
43
|
+
module Adapters
|
|
44
|
+
autoload :Base, 'rubyn_code/llm/adapters/base'
|
|
45
|
+
autoload :JsonParsing, 'rubyn_code/llm/adapters/json_parsing'
|
|
46
|
+
autoload :PromptCaching, 'rubyn_code/llm/adapters/prompt_caching'
|
|
47
|
+
autoload :Anthropic, 'rubyn_code/llm/adapters/anthropic'
|
|
48
|
+
autoload :AnthropicStreaming, 'rubyn_code/llm/adapters/anthropic_streaming'
|
|
49
|
+
autoload :OpenAI, 'rubyn_code/llm/adapters/openai'
|
|
50
|
+
autoload :OpenAIStreaming, 'rubyn_code/llm/adapters/openai_streaming'
|
|
51
|
+
autoload :OpenAICompatible, 'rubyn_code/llm/adapters/openai_compatible'
|
|
52
|
+
autoload :OpenAIMessageTranslator, 'rubyn_code/llm/adapters/openai_message_translator'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Backward-compat: LLM::Streaming → Adapters::AnthropicStreaming
|
|
56
|
+
autoload :Streaming, 'rubyn_code/llm/streaming'
|
|
37
57
|
end
|
|
38
58
|
|
|
39
59
|
# Layer 1: Agent Loop
|
|
@@ -41,6 +61,8 @@ module RubynCode
|
|
|
41
61
|
autoload :Loop, 'rubyn_code/agent/loop'
|
|
42
62
|
autoload :LoopDetector, 'rubyn_code/agent/loop_detector'
|
|
43
63
|
autoload :Conversation, 'rubyn_code/agent/conversation'
|
|
64
|
+
autoload :ResponseModes, 'rubyn_code/agent/response_modes'
|
|
65
|
+
autoload :DynamicToolSchema, 'rubyn_code/agent/dynamic_tool_schema'
|
|
44
66
|
end
|
|
45
67
|
|
|
46
68
|
# Layer 2: Tool System
|
|
@@ -72,11 +94,15 @@ module RubynCode
|
|
|
72
94
|
autoload :BackgroundRun, 'rubyn_code/tools/background_run'
|
|
73
95
|
autoload :WebSearch, 'rubyn_code/tools/web_search'
|
|
74
96
|
autoload :WebFetch, 'rubyn_code/tools/web_fetch'
|
|
97
|
+
autoload :AskUser, 'rubyn_code/tools/ask_user'
|
|
75
98
|
autoload :GitCommit, 'rubyn_code/tools/git_commit'
|
|
76
99
|
autoload :GitDiff, 'rubyn_code/tools/git_diff'
|
|
77
100
|
autoload :GitLog, 'rubyn_code/tools/git_log'
|
|
78
101
|
autoload :GitStatus, 'rubyn_code/tools/git_status'
|
|
79
102
|
autoload :SpawnTeammate, 'rubyn_code/tools/spawn_teammate'
|
|
103
|
+
autoload :OutputCompressor, 'rubyn_code/tools/output_compressor'
|
|
104
|
+
autoload :FileCache, 'rubyn_code/tools/file_cache'
|
|
105
|
+
autoload :SpecOutputParser, 'rubyn_code/tools/spec_output_parser'
|
|
80
106
|
end
|
|
81
107
|
|
|
82
108
|
# Layer 3: Permissions
|
|
@@ -95,6 +121,9 @@ module RubynCode
|
|
|
95
121
|
autoload :AutoCompact, 'rubyn_code/context/auto_compact'
|
|
96
122
|
autoload :ManualCompact, 'rubyn_code/context/manual_compact'
|
|
97
123
|
autoload :ContextCollapse, 'rubyn_code/context/context_collapse'
|
|
124
|
+
autoload :ContextBudget, 'rubyn_code/context/context_budget'
|
|
125
|
+
autoload :SchemaFilter, 'rubyn_code/context/schema_filter'
|
|
126
|
+
autoload :DecisionCompactor, 'rubyn_code/context/decision_compactor'
|
|
98
127
|
end
|
|
99
128
|
|
|
100
129
|
# Layer 5: Skills
|
|
@@ -102,6 +131,7 @@ module RubynCode
|
|
|
102
131
|
autoload :Loader, 'rubyn_code/skills/loader'
|
|
103
132
|
autoload :Catalog, 'rubyn_code/skills/catalog'
|
|
104
133
|
autoload :Document, 'rubyn_code/skills/document'
|
|
134
|
+
autoload :TtlManager, 'rubyn_code/skills/ttl_manager'
|
|
105
135
|
end
|
|
106
136
|
|
|
107
137
|
# Layer 6: Sub-Agents
|
|
@@ -160,6 +190,8 @@ module RubynCode
|
|
|
160
190
|
autoload :BudgetEnforcer, 'rubyn_code/observability/budget_enforcer'
|
|
161
191
|
autoload :UsageReporter, 'rubyn_code/observability/usage_reporter'
|
|
162
192
|
autoload :Models, 'rubyn_code/observability/models'
|
|
193
|
+
autoload :TokenAnalytics, 'rubyn_code/observability/token_analytics'
|
|
194
|
+
autoload :SkillAnalytics, 'rubyn_code/observability/skill_analytics'
|
|
163
195
|
end
|
|
164
196
|
|
|
165
197
|
# Layer 14: Hooks
|
|
@@ -185,6 +217,12 @@ module RubynCode
|
|
|
185
217
|
autoload :Instinct, 'rubyn_code/learning/instinct'
|
|
186
218
|
autoload :InstinctMethods, 'rubyn_code/learning/instinct'
|
|
187
219
|
autoload :Injector, 'rubyn_code/learning/injector'
|
|
220
|
+
autoload :Shortcut, 'rubyn_code/learning/shortcut'
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Codebase Index
|
|
224
|
+
module Index
|
|
225
|
+
autoload :CodebaseIndex, 'rubyn_code/index/codebase_index'
|
|
188
226
|
end
|
|
189
227
|
|
|
190
228
|
# CLI
|
|
@@ -223,6 +261,7 @@ module RubynCode
|
|
|
223
261
|
autoload :ContextInfo, 'rubyn_code/cli/commands/context_info'
|
|
224
262
|
autoload :Diff, 'rubyn_code/cli/commands/diff'
|
|
225
263
|
autoload :Model, 'rubyn_code/cli/commands/model'
|
|
264
|
+
autoload :NewSession, 'rubyn_code/cli/commands/new_session'
|
|
226
265
|
end
|
|
227
266
|
end
|
|
228
267
|
|