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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. 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. 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,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
- "Successfully wrote #{bytes} bytes to #{path}"
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
- VERSION = '0.2.2'
4
+ VERSION = '0.3.0'
5
5
  end
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