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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. 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. Useful for reading documentation, READMEs, or API docs.'
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: { type: :string, required: true, description: 'The URL to fetch (must start with http:// or https://)' },
14
- max_length: { type: :integer, required: false, default: 10_000,
15
- description: 'Maximum number of characters to return (default: 10000)' }
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 = [[max_length.to_i, 500].max, 100_000].min
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
- if text.strip.empty?
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, "Invalid URL: must start with http:// or https:// — got: #{url}"
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 = Faraday.new do |f|
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
- if [301, 302, 303, 307, 308].include?(response.status)
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
- # Remove script and style blocks entirely
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
- # Convert common block elements to newlines
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
- # Strip all remaining HTML tags
99
- text.gsub!(/<[^>]*>/, '')
100
-
101
- # Decode common HTML entities
119
+ def decode_html_entities!(text)
102
120
  text.gsub!('&amp;', '&')
103
121
  text.gsub!('&lt;', '<')
104
122
  text.gsub!('&gt;', '>')
@@ -106,12 +124,9 @@ module RubynCode
106
124
  text.gsub!('&#39;', "'")
107
125
  text.gsub!('&nbsp;', ' ')
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: { type: :integer, required: false, default: 5, description: 'Number of results (default: 5)' }
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 add new providers here
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 = [[num_results.to_i, 1].max, 20].min
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' if ENV['TAVILY_API_KEY']
51
- return 'brave' if ENV['BRAVE_API_KEY']
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
- # ─── DuckDuckGo (no API key, free) ───
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
- "https://lite.duckduckgo.com/lite/?q=#{encoded}"
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
- results = []
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
- # ─── Brave Search (free tier: 2000 queries/mo) ───
103
+ # --- Brave Search (free tier: 2000 queries/mo) ---
93
104
 
94
105
  def search_brave(query, num_results)
95
- resp = Faraday.get('https://api.search.brave.com/res/v1/web/search') do |req|
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
- # ─── Tavily (built for AI agents, free tier: 1000 queries/mo) ───
124
+ # --- Tavily (built for AI agents, free tier: 1000 queries/mo) ---
111
125
 
112
126
  def search_tavily(query, num_results)
113
- resp = Faraday.post('https://api.tavily.com/search') do |req|
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
- # ─── SerpAPI (free tier: 100 queries/mo) ───
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 = Faraday.get('https://serpapi.com/search.json') do |req|
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['organic_results'] || []).map do |r|
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
- # ─── Google Custom Search (free tier: 100 queries/day) ───
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
- # ─── Shared ───
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
- "Successfully wrote #{bytes} bytes to #{path}"
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
- VERSION = '0.2.2'
4
+ VERSION = '0.4.0'
5
5
  end
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