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
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Saves the current session and starts a fresh conversation.
7
+ # Like pressing Escape in Claude Code — clears context without quitting.
8
+ class NewSession < Base
9
+ def self.command_name = '/new'
10
+ def self.description = 'Save current session and start a fresh conversation'
11
+ def self.aliases = ['/reset'].freeze
12
+
13
+ def execute(_args, ctx)
14
+ save_current_session(ctx)
15
+ clear_conversation(ctx)
16
+ new_session_id = generate_session_id
17
+
18
+ ctx.renderer.info('Session saved. Starting fresh.')
19
+ ctx.renderer.info("New session: #{new_session_id[0..7]}")
20
+
21
+ { action: :new_session, session_id: new_session_id }
22
+ end
23
+
24
+ private
25
+
26
+ def save_current_session(ctx)
27
+ ctx.session_persistence.save_session(
28
+ session_id: ctx.session_id,
29
+ project_path: ctx.project_root,
30
+ messages: ctx.conversation.messages,
31
+ model: Config::Defaults::DEFAULT_MODEL
32
+ )
33
+ end
34
+
35
+ def clear_conversation(ctx)
36
+ ctx.conversation.clear!
37
+ end
38
+
39
+ def generate_session_id
40
+ SecureRandom.hex(16)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -18,14 +18,9 @@ module RubynCode
18
18
  end
19
19
 
20
20
  def run
21
- ensure_home_dir!
22
- ensure_auth!
23
- setup_database!
24
- display_banner!
25
-
21
+ bootstrap!
26
22
  daemon = build_daemon
27
23
  daemon.start!
28
-
29
24
  display_shutdown_summary(daemon)
30
25
  rescue Interrupt
31
26
  @renderer.info("\nShutting down daemon...")
@@ -38,6 +33,13 @@ module RubynCode
38
33
 
39
34
  private
40
35
 
36
+ def bootstrap!
37
+ ensure_home_dir!
38
+ ensure_auth!
39
+ setup_database!
40
+ display_banner!
41
+ end
42
+
41
43
  def build_daemon
42
44
  Autonomous::Daemon.new(
43
45
  agent_name: @daemon_opts[:agent_name],
@@ -79,13 +81,16 @@ module RubynCode
79
81
  end
80
82
 
81
83
  def ensure_auth!
82
- unless Auth::TokenStore.valid?
83
- @renderer.error('No valid authentication found.')
84
- @renderer.info('Run `rubyn-code --auth` or set ANTHROPIC_API_KEY first.')
84
+ provider = @daemon_opts.fetch(:provider, Config::Defaults::DEFAULT_PROVIDER)
85
+
86
+ unless Auth::TokenStore.load_for_provider(provider)
87
+ @renderer.error("No valid authentication found for provider '#{provider}'.")
88
+ env_key = Config::Defaults::PROVIDER_ENV_KEYS.fetch(provider, "#{provider.upcase}_API_KEY")
89
+ @renderer.info("Set #{env_key} or run `rubyn-code --auth`.")
85
90
  exit(1)
86
91
  end
87
92
 
88
- @llm_client = LLM::Client.new
93
+ @llm_client = LLM::Client.new(provider: provider)
89
94
  end
90
95
 
91
96
  def setup_database!
@@ -96,9 +101,18 @@ module RubynCode
96
101
  end
97
102
 
98
103
  def display_banner!
104
+ display_banner_header!
105
+ display_banner_details!
106
+ display_banner_footer!
107
+ end
108
+
109
+ def display_banner_header!
99
110
  @renderer.info('╔══════════════════════════════════════╗')
100
111
  @renderer.info('║ GOLEM Daemon Starting ║')
101
112
  @renderer.info('╚══════════════════════════════════════╝')
113
+ end
114
+
115
+ def display_banner_details!
102
116
  @renderer.info(" Agent: #{@daemon_opts[:agent_name]}")
103
117
  @renderer.info(" Role: #{@daemon_opts[:role]}")
104
118
  @renderer.info(" Project: #{@project_root}")
@@ -106,6 +120,9 @@ module RubynCode
106
120
  @renderer.info(" Max cost: $#{@daemon_opts[:max_cost]}")
107
121
  @renderer.info(" Idle timeout: #{@daemon_opts[:idle_timeout]}s")
108
122
  @renderer.info(" Poll interval: #{@daemon_opts[:poll_interval]}s")
123
+ end
124
+
125
+ def display_banner_footer!
109
126
  @renderer.info('')
110
127
  @renderer.info('Waiting for tasks... (Ctrl-C to stop)')
111
128
  @renderer.info('Seed tasks via the REPL: /tasks or the task tool.')
@@ -122,7 +139,7 @@ module RubynCode
122
139
  @renderer.info('╚══════════════════════════════════════╝')
123
140
  @renderer.info(" Final state: #{status[:state]}")
124
141
  @renderer.info(" Tasks completed: #{status[:runs_completed]}")
125
- @renderer.info(" Total cost: $#{'%.4f' % status[:total_cost]}")
142
+ @renderer.info(format(' Total cost: $%.4f', status[:total_cost]))
126
143
  end
127
144
  end
128
145
  end
@@ -33,14 +33,23 @@ module RubynCode
33
33
  puts @pastel.cyan(" > #{name}: #{format_params(params)}")
34
34
  end
35
35
 
36
- def tool_result(_name, output)
37
- truncated = output.to_s[0...300]
38
- lines = truncated.lines
39
- if lines.length > 6
40
- puts @pastel.dim(" #{lines[0..4].map(&:strip).join("\n ")}")
41
- puts @pastel.dim(" ... (#{lines.length - 5} more lines)")
36
+ DIFF_TOOLS = %w[edit_file write_file].freeze
37
+ DIFF_RESULT_LIMIT = 2000
38
+ DEFAULT_RESULT_LIMIT = 500
39
+
40
+ def tool_result(name, output)
41
+ raw = output.to_s
42
+
43
+ if DIFF_TOOLS.include?(name.to_s)
44
+ render_diff_result(raw[0...DIFF_RESULT_LIMIT].lines)
42
45
  else
43
- puts @pastel.dim(" #{truncated.strip.gsub("\n", "\n ")}")
46
+ truncated = raw[0...DEFAULT_RESULT_LIMIT]
47
+ lines = truncated.lines
48
+ if lines.length > 6
49
+ render_truncated_result(lines)
50
+ else
51
+ puts @pastel.dim(" #{truncated.strip.gsub("\n", "\n ")}")
52
+ end
44
53
  end
45
54
  end
46
55
 
@@ -80,54 +89,93 @@ module RubynCode
80
89
 
81
90
  def cost_summary(session_cost:, daily_cost:, tokens:)
82
91
  puts @pastel.bold('Cost Summary:')
83
- puts " Session: $#{'%.4f' % session_cost}"
84
- puts " Today: $#{'%.4f' % daily_cost}"
92
+ puts format(' Session: $%.4f', session_cost)
93
+ puts format(' Today: $%.4f', daily_cost)
85
94
  puts " Tokens: #{tokens[:input]} in / #{tokens[:output]} out"
86
95
  end
87
96
 
88
97
  def prompt
89
- if @yolo
90
- @pastel.bold.green('rubyn ') + @pastel.bold.red('YOLO') + @pastel.bold.green(' > ')
91
- else
92
- @pastel.bold.green('rubyn > ')
93
- end
98
+ return @pastel.bold.green('rubyn ') + @pastel.bold.red('YOLO') + @pastel.bold.green(' > ') if @yolo
99
+
100
+ @pastel.bold.green('rubyn > ')
94
101
  end
95
102
 
103
+ DIFF_COLORS = {
104
+ /\A \+ / => :green,
105
+ /\A - / => :red,
106
+ /\A @@ / => :cyan,
107
+ /\A(?:Created|Updated|Edited) / => :yellow
108
+ }.freeze
109
+
96
110
  private
97
111
 
112
+ def render_diff_result(lines)
113
+ lines.each do |line|
114
+ stripped = line.rstrip
115
+ puts " #{colorize_diff_line(stripped)}"
116
+ end
117
+ end
118
+
119
+ def colorize_diff_line(line)
120
+ DIFF_COLORS.each do |pattern, color|
121
+ return @pastel.decorate(line, color) if line.match?(pattern)
122
+ end
123
+ @pastel.dim(line)
124
+ end
125
+
126
+ def render_truncated_result(lines)
127
+ puts @pastel.dim(" #{lines[0..4].map(&:strip).join("\n ")}")
128
+ puts @pastel.dim(" ... (#{lines.length - 5} more lines)")
129
+ end
130
+
98
131
  def render_markdown(text)
99
132
  lines = text.lines
100
133
  result = []
101
-
102
134
  in_code_block = false
103
135
  code_lang = nil
104
136
  code_buffer = []
105
137
 
106
138
  lines.each do |line|
107
- if line.strip.match?(/\A```(\w*)/)
108
- if in_code_block
109
- result << render_code_block(code_buffer.join, code_lang)
110
- code_buffer = []
111
- in_code_block = false
112
- code_lang = nil
113
- else
114
- in_code_block = true
115
- code_lang = line.strip.match(/\A```(\w*)/)[1]
116
- code_lang = 'ruby' if code_lang.empty?
117
- end
118
- elsif in_code_block
119
- code_buffer << line
120
- else
121
- result << render_line(line)
122
- end
139
+ in_code_block, code_lang, code_buffer = process_markdown_line(
140
+ line, in_code_block, code_lang, code_buffer, result
141
+ )
123
142
  end
124
143
 
125
144
  # Flush any unclosed code block
126
- result << render_code_block(code_buffer.join, code_lang || 'ruby') unless code_buffer.empty?
145
+ flush_code_buffer(code_buffer, code_lang, result)
127
146
 
128
147
  result.join
129
148
  end
130
149
 
150
+ def process_markdown_line(line, in_code_block, code_lang, code_buffer, result)
151
+ if line.strip.match?(/\A```(\w*)/)
152
+ handle_code_fence(line, in_code_block, code_lang, code_buffer, result)
153
+ elsif in_code_block
154
+ code_buffer << line
155
+ [in_code_block, code_lang, code_buffer]
156
+ else
157
+ result << render_line(line)
158
+ [in_code_block, code_lang, code_buffer]
159
+ end
160
+ end
161
+
162
+ def handle_code_fence(line, in_code_block, code_lang, code_buffer, result)
163
+ if in_code_block
164
+ result << render_code_block(code_buffer.join, code_lang)
165
+ [false, nil, []]
166
+ else
167
+ lang = line.strip.match(/\A```(\w*)/)[1]
168
+ lang = 'ruby' if lang.empty?
169
+ [true, lang, []]
170
+ end
171
+ end
172
+
173
+ def flush_code_buffer(code_buffer, code_lang, result)
174
+ return if code_buffer.empty?
175
+
176
+ result << render_code_block(code_buffer.join, code_lang || 'ruby')
177
+ end
178
+
131
179
  def render_code_block(code, lang)
132
180
  lexer = Rouge::Lexer.find(lang) || Rouge::Lexers::PlainText.new
133
181
  highlighted = @rouge_formatter.format(lexer.lex(code))
@@ -139,37 +187,38 @@ module RubynCode
139
187
  end
140
188
 
141
189
  def render_line(line)
142
- # Headers
143
- if line.match?(/\A\s*\#{1,6}\s/)
144
- level = line.match(/\A\s*(\#{1,6})\s/)[1].length
145
- text = line.sub(/\A\s*\#{1,6}\s+/, '').rstrip
146
- case level
147
- when 1 then "#{@pastel.bold.underline(text)}\n"
148
- when 2 then "#{@pastel.bold(text)}\n"
149
- else "#{@pastel.bold(text)}\n"
150
- end
151
- # Bullet lists
152
- elsif line.match?(/\A\s*[-*]\s/)
153
- indent = line.match(/\A(\s*)/)[1]
154
- content = line.sub(/\A\s*[-*]\s+/, '')
155
- "#{indent} #{@pastel.cyan('•')} #{render_inline(content)}"
156
- # Numbered lists
157
- elsif line.match?(/\A\s*\d+\.\s/)
158
- indent = line.match(/\A(\s*)/)[1]
159
- num = line.match(/(\d+)\./)[1]
160
- content = line.sub(/\A\s*\d+\.\s+/, '')
161
- "#{indent} #{@pastel.cyan("#{num}.")} #{render_inline(content)}"
162
- # Horizontal rules
163
- elsif line.strip.match?(/\A-{3,}\z/)
164
- "#{@pastel.dim('─' * [terminal_width - 4, 40].min)}\n"
165
- # Table rows
166
- elsif line.include?('|')
167
- render_table_row(line)
168
- else
169
- render_inline(line)
190
+ case line
191
+ when /\A\s*\#{1,6}\s/ then render_header(line)
192
+ when /\A\s*[-*]\s/ then render_bullet(line)
193
+ when /\A\s*\d+\.\s/ then render_numbered_item(line)
194
+ when ->(l) { l.strip.match?(/\A-{3,}\z/) } then "#{@pastel.dim('─' * [terminal_width - 4, 40].min)}\n"
195
+ when /\|/ then render_table_row(line)
196
+ else render_inline(line)
197
+ end
198
+ end
199
+
200
+ def render_header(line)
201
+ level = line.match(/\A\s*(\#{1,6})\s/)[1].length
202
+ text = line.sub(/\A\s*\#{1,6}\s+/, '').rstrip
203
+ case level
204
+ when 1 then "#{@pastel.bold.underline(text)}\n"
205
+ else "#{@pastel.bold(text)}\n"
170
206
  end
171
207
  end
172
208
 
209
+ def render_bullet(line)
210
+ indent = line.match(/\A(\s*)/)[1]
211
+ content = line.sub(/\A\s*[-*]\s+/, '')
212
+ "#{indent} #{@pastel.cyan('•')} #{render_inline(content)}"
213
+ end
214
+
215
+ def render_numbered_item(line)
216
+ indent = line.match(/\A(\s*)/)[1]
217
+ num = line.match(/(\d+)\./)[1]
218
+ content = line.sub(/\A\s*\d+\.\s+/, '')
219
+ "#{indent} #{@pastel.cyan("#{num}.")} #{render_inline(content)}"
220
+ end
221
+
173
222
  def render_inline(text)
174
223
  text
175
224
  .gsub(/\*\*(.+?)\*\*/) { @pastel.bold(Regexp.last_match(1)) }