rubyn-code 0.2.2 → 0.4.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 +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -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 +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -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 +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -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 +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- 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/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +311 -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 +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -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 +50 -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 +75 -247
- 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 +10 -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/search.rb +1 -0
- 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/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- 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/tasks/models.rb +1 -0
- 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 +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- 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 +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- 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 +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- 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 +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- metadata +83 -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,88 @@ module RubynCode
|
|
|
16
16
|
RISK_LEVEL = :write
|
|
17
17
|
REQUIRES_CONFIRMATION = false
|
|
18
18
|
|
|
19
|
+
PREVIEW_LINES = 15
|
|
20
|
+
|
|
21
|
+
# Take the first line of the tool's output, which is already formatted
|
|
22
|
+
# as "Updated /path.rb (N bytes)" or "Created /path.rb (N bytes)".
|
|
23
|
+
def self.summarize(output, _args)
|
|
24
|
+
output.to_s.lines.first.to_s.chomp[0, 200]
|
|
25
|
+
end
|
|
26
|
+
|
|
19
27
|
def execute(path:, content:)
|
|
20
28
|
resolved = safe_path(path)
|
|
29
|
+
existed = File.exist?(resolved)
|
|
30
|
+
old_content = existed ? File.read(resolved) : nil
|
|
21
31
|
|
|
22
32
|
FileUtils.mkdir_p(File.dirname(resolved))
|
|
23
33
|
bytes = File.write(resolved, content)
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
format_result(path, bytes, existed, old_content, content)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Compute the proposed file content without writing to disk.
|
|
39
|
+
# Used by IDE mode to preview the write in a diff view (modify) or
|
|
40
|
+
# preview tab (create) before the user accepts.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash] { content: String, type: 'modify' | 'create' }
|
|
43
|
+
def preview_content(path:, content:)
|
|
44
|
+
resolved = safe_path(path)
|
|
45
|
+
type = File.exist?(resolved) ? 'modify' : 'create'
|
|
46
|
+
{ content: content, type: type }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def format_result(path, bytes, existed, old_content, new_content)
|
|
52
|
+
lines = []
|
|
53
|
+
|
|
54
|
+
if existed
|
|
55
|
+
lines << "Updated #{path} (#{bytes} bytes)"
|
|
56
|
+
lines << diff_preview(old_content, new_content)
|
|
57
|
+
else
|
|
58
|
+
lines << "Created #{path} (#{bytes} bytes)"
|
|
59
|
+
lines << file_preview(new_content)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
lines.compact.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def file_preview(content)
|
|
66
|
+
preview_lines = content.lines.first(PREVIEW_LINES)
|
|
67
|
+
preview = preview_lines.each_with_index.map do |line, idx|
|
|
68
|
+
" #{(idx + 1).to_s.rjust(3)}│ #{line.chomp}"
|
|
69
|
+
end.join("\n")
|
|
70
|
+
|
|
71
|
+
remaining = content.lines.count - PREVIEW_LINES
|
|
72
|
+
preview += "\n ... (#{remaining} more lines)" if remaining.positive?
|
|
73
|
+
preview
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def diff_preview(old_content, new_content)
|
|
77
|
+
old_lines = (old_content || '').lines.map(&:chomp)
|
|
78
|
+
new_lines = new_content.lines.map(&:chomp)
|
|
79
|
+
|
|
80
|
+
changes = simple_diff(old_lines, new_lines)
|
|
81
|
+
return ' (no visible changes)' if changes.empty?
|
|
82
|
+
|
|
83
|
+
changes.first(20).join("\n")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def simple_diff(old_lines, new_lines)
|
|
87
|
+
changes = []
|
|
88
|
+
max = [old_lines.length, new_lines.length].max
|
|
89
|
+
|
|
90
|
+
max.times do |idx|
|
|
91
|
+
old_line = old_lines[idx]
|
|
92
|
+
new_line = new_lines[idx]
|
|
93
|
+
|
|
94
|
+
next if old_line == new_line
|
|
95
|
+
|
|
96
|
+
changes << " - #{old_line}" if old_line
|
|
97
|
+
changes << " + #{new_line}" if new_line
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
changes
|
|
26
101
|
end
|
|
27
102
|
end
|
|
28
103
|
|
data/lib/rubyn_code/version.rb
CHANGED
data/lib/rubyn_code.rb
CHANGED
|
@@ -11,10 +11,18 @@ module RubynCode
|
|
|
11
11
|
class StallDetectedError < Error; end
|
|
12
12
|
class ToolNotFoundError < Error; end
|
|
13
13
|
class ConfigError < Error; end
|
|
14
|
+
# Raised when the user refuses a tool invocation in IDE mode. Signals the
|
|
15
|
+
# agent loop to surface this as is_error: true so the model sees a refusal
|
|
16
|
+
# rather than a successful tool call returning a string like "denied".
|
|
17
|
+
class UserDeniedError < Error; end
|
|
14
18
|
|
|
15
19
|
# Infrastructure
|
|
16
20
|
autoload :Config, 'rubyn_code/config/settings'
|
|
17
21
|
|
|
22
|
+
module Config
|
|
23
|
+
autoload :ProjectProfile, 'rubyn_code/config/project_profile'
|
|
24
|
+
end
|
|
25
|
+
|
|
18
26
|
# Database
|
|
19
27
|
module DB
|
|
20
28
|
autoload :Connection, 'rubyn_code/db/connection'
|
|
@@ -25,6 +33,7 @@ module RubynCode
|
|
|
25
33
|
# Auth
|
|
26
34
|
module Auth
|
|
27
35
|
autoload :OAuth, 'rubyn_code/auth/oauth'
|
|
36
|
+
autoload :KeyEncryption, 'rubyn_code/auth/key_encryption'
|
|
28
37
|
autoload :TokenStore, 'rubyn_code/auth/token_store'
|
|
29
38
|
autoload :Server, 'rubyn_code/auth/server'
|
|
30
39
|
end
|
|
@@ -32,8 +41,25 @@ module RubynCode
|
|
|
32
41
|
# LLM
|
|
33
42
|
module LLM
|
|
34
43
|
autoload :Client, 'rubyn_code/llm/client'
|
|
35
|
-
autoload :Streaming, 'rubyn_code/llm/streaming'
|
|
36
44
|
autoload :MessageBuilder, 'rubyn_code/llm/message_builder'
|
|
45
|
+
autoload :ModelRouter, 'rubyn_code/llm/model_router'
|
|
46
|
+
|
|
47
|
+
# Adapters (provider-specific implementations)
|
|
48
|
+
module Adapters
|
|
49
|
+
autoload :Base, 'rubyn_code/llm/adapters/base'
|
|
50
|
+
autoload :JsonParsing, 'rubyn_code/llm/adapters/json_parsing'
|
|
51
|
+
autoload :PromptCaching, 'rubyn_code/llm/adapters/prompt_caching'
|
|
52
|
+
autoload :Anthropic, 'rubyn_code/llm/adapters/anthropic'
|
|
53
|
+
autoload :AnthropicCompatible, 'rubyn_code/llm/adapters/anthropic_compatible'
|
|
54
|
+
autoload :AnthropicStreaming, 'rubyn_code/llm/adapters/anthropic_streaming'
|
|
55
|
+
autoload :OpenAI, 'rubyn_code/llm/adapters/openai'
|
|
56
|
+
autoload :OpenAIStreaming, 'rubyn_code/llm/adapters/openai_streaming'
|
|
57
|
+
autoload :OpenAICompatible, 'rubyn_code/llm/adapters/openai_compatible'
|
|
58
|
+
autoload :OpenAIMessageTranslator, 'rubyn_code/llm/adapters/openai_message_translator'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Backward-compat: LLM::Streaming → Adapters::AnthropicStreaming
|
|
62
|
+
autoload :Streaming, 'rubyn_code/llm/streaming'
|
|
37
63
|
end
|
|
38
64
|
|
|
39
65
|
# Layer 1: Agent Loop
|
|
@@ -41,6 +67,8 @@ module RubynCode
|
|
|
41
67
|
autoload :Loop, 'rubyn_code/agent/loop'
|
|
42
68
|
autoload :LoopDetector, 'rubyn_code/agent/loop_detector'
|
|
43
69
|
autoload :Conversation, 'rubyn_code/agent/conversation'
|
|
70
|
+
autoload :ResponseModes, 'rubyn_code/agent/response_modes'
|
|
71
|
+
autoload :DynamicToolSchema, 'rubyn_code/agent/dynamic_tool_schema'
|
|
44
72
|
end
|
|
45
73
|
|
|
46
74
|
# Layer 2: Tool System
|
|
@@ -72,11 +100,15 @@ module RubynCode
|
|
|
72
100
|
autoload :BackgroundRun, 'rubyn_code/tools/background_run'
|
|
73
101
|
autoload :WebSearch, 'rubyn_code/tools/web_search'
|
|
74
102
|
autoload :WebFetch, 'rubyn_code/tools/web_fetch'
|
|
103
|
+
autoload :AskUser, 'rubyn_code/tools/ask_user'
|
|
75
104
|
autoload :GitCommit, 'rubyn_code/tools/git_commit'
|
|
76
105
|
autoload :GitDiff, 'rubyn_code/tools/git_diff'
|
|
77
106
|
autoload :GitLog, 'rubyn_code/tools/git_log'
|
|
78
107
|
autoload :GitStatus, 'rubyn_code/tools/git_status'
|
|
79
108
|
autoload :SpawnTeammate, 'rubyn_code/tools/spawn_teammate'
|
|
109
|
+
autoload :OutputCompressor, 'rubyn_code/tools/output_compressor'
|
|
110
|
+
autoload :FileCache, 'rubyn_code/tools/file_cache'
|
|
111
|
+
autoload :SpecOutputParser, 'rubyn_code/tools/spec_output_parser'
|
|
80
112
|
end
|
|
81
113
|
|
|
82
114
|
# Layer 3: Permissions
|
|
@@ -95,6 +127,9 @@ module RubynCode
|
|
|
95
127
|
autoload :AutoCompact, 'rubyn_code/context/auto_compact'
|
|
96
128
|
autoload :ManualCompact, 'rubyn_code/context/manual_compact'
|
|
97
129
|
autoload :ContextCollapse, 'rubyn_code/context/context_collapse'
|
|
130
|
+
autoload :ContextBudget, 'rubyn_code/context/context_budget'
|
|
131
|
+
autoload :SchemaFilter, 'rubyn_code/context/schema_filter'
|
|
132
|
+
autoload :DecisionCompactor, 'rubyn_code/context/decision_compactor'
|
|
98
133
|
end
|
|
99
134
|
|
|
100
135
|
# Layer 5: Skills
|
|
@@ -102,6 +137,7 @@ module RubynCode
|
|
|
102
137
|
autoload :Loader, 'rubyn_code/skills/loader'
|
|
103
138
|
autoload :Catalog, 'rubyn_code/skills/catalog'
|
|
104
139
|
autoload :Document, 'rubyn_code/skills/document'
|
|
140
|
+
autoload :TtlManager, 'rubyn_code/skills/ttl_manager'
|
|
105
141
|
end
|
|
106
142
|
|
|
107
143
|
# Layer 6: Sub-Agents
|
|
@@ -160,6 +196,8 @@ module RubynCode
|
|
|
160
196
|
autoload :BudgetEnforcer, 'rubyn_code/observability/budget_enforcer'
|
|
161
197
|
autoload :UsageReporter, 'rubyn_code/observability/usage_reporter'
|
|
162
198
|
autoload :Models, 'rubyn_code/observability/models'
|
|
199
|
+
autoload :TokenAnalytics, 'rubyn_code/observability/token_analytics'
|
|
200
|
+
autoload :SkillAnalytics, 'rubyn_code/observability/skill_analytics'
|
|
163
201
|
end
|
|
164
202
|
|
|
165
203
|
# Layer 14: Hooks
|
|
@@ -185,6 +223,25 @@ module RubynCode
|
|
|
185
223
|
autoload :Instinct, 'rubyn_code/learning/instinct'
|
|
186
224
|
autoload :InstinctMethods, 'rubyn_code/learning/instinct'
|
|
187
225
|
autoload :Injector, 'rubyn_code/learning/injector'
|
|
226
|
+
autoload :Shortcut, 'rubyn_code/learning/shortcut'
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# IDE (VS Code extension server)
|
|
230
|
+
module IDE
|
|
231
|
+
autoload :Protocol, 'rubyn_code/ide/protocol'
|
|
232
|
+
autoload :Server, 'rubyn_code/ide/server'
|
|
233
|
+
|
|
234
|
+
module Adapters
|
|
235
|
+
autoload :ToolOutput, 'rubyn_code/ide/adapters/tool_output'
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Self-Test
|
|
240
|
+
autoload :SelfTest, 'rubyn_code/self_test'
|
|
241
|
+
|
|
242
|
+
# Codebase Index
|
|
243
|
+
module Index
|
|
244
|
+
autoload :CodebaseIndex, 'rubyn_code/index/codebase_index'
|
|
188
245
|
end
|
|
189
246
|
|
|
190
247
|
# CLI
|
|
@@ -196,6 +253,7 @@ module RubynCode
|
|
|
196
253
|
autoload :Spinner, 'rubyn_code/cli/spinner'
|
|
197
254
|
autoload :StreamFormatter, 'rubyn_code/cli/stream_formatter'
|
|
198
255
|
autoload :Setup, 'rubyn_code/cli/setup'
|
|
256
|
+
autoload :FirstRun, 'rubyn_code/cli/first_run'
|
|
199
257
|
autoload :DaemonRunner, 'rubyn_code/cli/daemon_runner'
|
|
200
258
|
autoload :VersionCheck, 'rubyn_code/cli/version_check'
|
|
201
259
|
|
|
@@ -222,7 +280,10 @@ module RubynCode
|
|
|
222
280
|
autoload :Plan, 'rubyn_code/cli/commands/plan'
|
|
223
281
|
autoload :ContextInfo, 'rubyn_code/cli/commands/context_info'
|
|
224
282
|
autoload :Diff, 'rubyn_code/cli/commands/diff'
|
|
283
|
+
autoload :Mcp, 'rubyn_code/cli/commands/mcp'
|
|
225
284
|
autoload :Model, 'rubyn_code/cli/commands/model'
|
|
285
|
+
autoload :NewSession, 'rubyn_code/cli/commands/new_session'
|
|
286
|
+
autoload :Provider, 'rubyn_code/cli/commands/provider'
|
|
226
287
|
end
|
|
227
288
|
end
|
|
228
289
|
|