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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ # Session persistence, shutdown, and learning extraction for the REPL.
6
+ module ReplLifecycle
7
+ GOODBYE_MESSAGES = [
8
+ 'Freezing strings and saving memories... See ya! 💎',
9
+ 'Memoizing this session... Until next time! 🧠',
10
+ 'Committing learnings to memory... Later! 🤙',
11
+ 'Saving state, yielding control... Bye for now! 👋',
12
+ 'Session.save! && Rubyn.sleep... Catch you later! 😴',
13
+ "GC.start on this session... Stay Ruby, friend! \u270C\uFE0F",
14
+ "Writing instincts to disk... Don't forget me! 💾",
15
+ "at_exit { puts 'Thanks for coding with Rubyn!' } 🎸"
16
+ ].freeze
17
+
18
+ private
19
+
20
+ def current_session_id
21
+ @current_session_id ||= SecureRandom.hex(16)
22
+ end
23
+
24
+ def save_session!
25
+ @session_persistence.save_session(
26
+ session_id: current_session_id,
27
+ project_path: @project_root,
28
+ messages: @conversation.messages,
29
+ model: Config::Defaults::DEFAULT_MODEL
30
+ )
31
+ end
32
+
33
+ def resume_session!
34
+ data = @session_persistence.load_session(@session_id)
35
+ return unless data
36
+
37
+ @conversation.replace!(data[:messages])
38
+ @renderer.info("Resumed session #{@session_id[0..7]}")
39
+ end
40
+
41
+ def shutdown!
42
+ return if @shutdown_complete
43
+
44
+ @shutdown_complete = true
45
+ @spinner.stop
46
+ puts
47
+ @renderer.info(GOODBYE_MESSAGES.sample)
48
+ @renderer.info('Saving session...')
49
+ save_session!
50
+ @background_worker&.shutdown!
51
+ extract_learnings_if_needed
52
+ decay_instincts
53
+ @renderer.info("Session saved. Rubyn out. \u270C\uFE0F")
54
+ rescue StandardError
55
+ # Best effort on shutdown
56
+ end
57
+
58
+ def extract_learnings_if_needed
59
+ return unless @conversation.length > 5
60
+
61
+ @renderer.info('Extracting learnings from this session...')
62
+ Learning::Extractor.call(@conversation.messages, llm_client: @llm_client, project_path: @project_root)
63
+ @renderer.success('Instincts saved.')
64
+ rescue StandardError => e
65
+ RubynCode::Debug.warn("Instinct extraction skipped: #{e.message}")
66
+ end
67
+
68
+ def decay_instincts
69
+ Learning::InstinctMethods.decay_all(DB::Connection.instance, project_path: @project_root)
70
+ rescue StandardError
71
+ # Silent — decay is best-effort
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ # Infrastructure and service setup for the REPL.
6
+ module ReplSetup # rubocop:disable Metrics/ModuleLength -- REPL setup requires many service initializations
7
+ private
8
+
9
+ def setup_components!
10
+ setup_infrastructure!
11
+ setup_services!
12
+ setup_executor_callbacks!
13
+ setup_hooks!
14
+ setup_agent_loop!
15
+ end
16
+
17
+ def setup_infrastructure!
18
+ ensure_home_dir!
19
+ @db = DB::Connection.instance
20
+ DB::Migrator.new(@db).migrate!
21
+ @auth = ensure_auth!
22
+ end
23
+
24
+ def setup_services!
25
+ setup_core_services!
26
+ setup_auxiliary_services!
27
+ end
28
+
29
+ def setup_core_services!
30
+ @llm_client = LLM::Client.new
31
+ @conversation = Agent::Conversation.new
32
+ @tool_executor = Tools::Executor.new(project_root: @project_root)
33
+ @context_manager = Context::Manager.new(llm_client: @llm_client)
34
+ @hook_registry = Hooks::Registry.new
35
+ @hook_runner = Hooks::Runner.new(registry: @hook_registry)
36
+ @stall_detector = Agent::LoopDetector.new
37
+ end
38
+
39
+ def setup_auxiliary_services!
40
+ @deny_list = Permissions::DenyList.new
41
+ @budget_enforcer = Observability::BudgetEnforcer.new(@db, session_id: current_session_id)
42
+ @background_worker = Background::Worker.new(project_root: @project_root)
43
+ @skill_loader = Skills::Loader.new(Skills::Catalog.new(skill_dirs))
44
+ @session_persistence = Memory::SessionPersistence.new(@db)
45
+ end
46
+
47
+ def setup_executor_callbacks!
48
+ @tool_executor.llm_client = @llm_client
49
+ @tool_executor.background_worker = @background_worker
50
+ @tool_executor.db = @db
51
+ @tool_executor.ask_user_callback = build_ask_user_callback
52
+ @sub_agent_tool_count = 0
53
+ @in_sub_agent = false
54
+ @tool_executor.on_agent_status = build_agent_status_callback
55
+ end
56
+
57
+ def build_ask_user_callback
58
+ ->(question) { prompt_user_for_answer(question) }
59
+ end
60
+
61
+ def prompt_user_for_answer(question)
62
+ @spinner.stop
63
+ @renderer.warning('Rubyn is asking:')
64
+ puts " #{question}"
65
+ print ' > '
66
+ $stdout.flush
67
+ answer = Reline.readline('', false)&.strip
68
+ answer.nil? || answer.empty? ? '[no response]' : answer
69
+ end
70
+
71
+ def build_agent_status_callback
72
+ ->(type, msg) { handle_agent_status(type, msg) }
73
+ end
74
+
75
+ def handle_agent_status(type, msg)
76
+ case type
77
+ when :started
78
+ @spinner.stop
79
+ @in_sub_agent = true
80
+ @sub_agent_tool_count = 0
81
+ @renderer.info(msg)
82
+ @spinner.start_sub_agent
83
+ when :tool
84
+ @sub_agent_tool_count += 1
85
+ @spinner.stop
86
+ @spinner.start_sub_agent(@sub_agent_tool_count)
87
+ when :done
88
+ @spinner.stop
89
+ @in_sub_agent = false
90
+ @renderer.success(msg)
91
+ end
92
+ end
93
+
94
+ def setup_hooks!
95
+ Hooks::BuiltIn.register_all!(@hook_registry)
96
+ Hooks::UserHooks.load!(@hook_registry, project_root: @project_root)
97
+ end
98
+
99
+ def setup_agent_loop!
100
+ @agent_loop = Agent::Loop.new(
101
+ llm_client: @llm_client, tool_executor: @tool_executor,
102
+ context_manager: @context_manager, hook_runner: @hook_runner,
103
+ conversation: @conversation, permission_tier: @permission_tier,
104
+ deny_list: @deny_list, budget_enforcer: @budget_enforcer,
105
+ background_manager: @background_worker, stall_detector: @stall_detector,
106
+ on_tool_call: ->(name, params) { handle_on_tool_call(name, params) },
107
+ on_tool_result: ->(name, result, _is_error = false) { handle_on_tool_result(name, result) },
108
+ on_text: ->(text) { handle_on_text(text) },
109
+ skill_loader: @skill_loader, project_root: @project_root
110
+ )
111
+ end
112
+
113
+ def ensure_home_dir!
114
+ FileUtils.mkdir_p(Config::Defaults::HOME_DIR)
115
+ end
116
+
117
+ def ensure_auth!
118
+ provider = Config::Defaults::DEFAULT_PROVIDER
119
+ tokens = Auth::TokenStore.load_for_provider(provider)
120
+
121
+ if tokens
122
+ source = tokens.fetch(:source, :unknown)
123
+ @renderer.info("Authenticated via #{source}") if source == :keychain
124
+ return true
125
+ end
126
+
127
+ @renderer.error('No valid authentication found.')
128
+ @renderer.info('Options:')
129
+ @renderer.info(' 1. Run Claude Code once to authenticate (Rubyn Code reads the keychain token)')
130
+ @renderer.info(' 2. Set ANTHROPIC_API_KEY environment variable')
131
+ @renderer.info(" 3. Run 'rubyn-code --auth' to enter an API key")
132
+ exit(1)
133
+ end
134
+
135
+ def skill_dirs
136
+ dirs = [File.expand_path('../../../skills', __dir__)]
137
+ project_skills = File.join(@project_root, '.rubyn-code', 'skills')
138
+ dirs << project_skills if Dir.exist?(project_skills)
139
+ user_skills = File.join(Config::Defaults::HOME_DIR, 'skills')
140
+ dirs << user_skills if Dir.exist?(user_skills)
141
+ dirs
142
+ end
143
+ end
144
+ end
145
+ end
@@ -83,15 +83,19 @@ module RubynCode
83
83
  end
84
84
 
85
85
  def write_launcher(path, gem_wrapper, pinned_ruby)
86
+ ruby_version = File.basename(File.dirname(pinned_ruby, 2))
86
87
  File.write(path, <<~BASH)
87
88
  #!/usr/bin/env bash
88
- # Rubyn Code launcher — pinned to Ruby #{File.basename(File.dirname(pinned_ruby, 2))}
89
+ # Rubyn Code launcher — pinned to Ruby #{ruby_version}
89
90
  # Generated by: rubyn-code --setup
90
91
  # Bypasses rbenv/rvm so rubyn-code works in any project.
91
92
  #
93
+ # Uses the pinned Ruby directly (not /usr/bin/env ruby) to avoid
94
+ # rbenv resolving to a different Ruby based on .ruby-version.
95
+ #
92
96
  # To regenerate: rubyn-code --setup
93
97
  # To remove: rm #{path}
94
- exec "#{gem_wrapper}" "$@"
98
+ exec "#{pinned_ruby}" "#{gem_wrapper}" "$@"
95
99
  BASH
96
100
  end
97
101
 
@@ -58,80 +58,64 @@ module RubynCode
58
58
  def process_line(line)
59
59
  stripped = line.rstrip
60
60
 
61
- # Code block toggle
62
61
  if stripped.match?(/\A\s*```/)
63
- if @in_code_block
64
- # Closing fence — render the buffered code
65
- render_code_block
66
- @in_code_block = false
67
- @code_lang = nil
68
- else
69
- # Opening fence
70
- @in_code_block = true
71
- @code_lang = stripped.match(/```(\w*)/)[1]
72
- @code_lang = 'ruby' if @code_lang.empty?
73
- @code_buffer = +''
74
- $stdout.puts @pastel.dim(" ┌─ #{@code_lang}")
75
- end
76
- return
62
+ toggle_code_block(stripped)
63
+ elsif @in_code_block
64
+ @code_buffer << line
65
+ else
66
+ $stdout.print format_line(line)
67
+ $stdout.flush
77
68
  end
69
+ end
78
70
 
71
+ def toggle_code_block(stripped)
79
72
  if @in_code_block
80
- @code_buffer << line
81
- return
73
+ render_code_block
74
+ @in_code_block = false
75
+ @code_lang = nil
76
+ else
77
+ open_code_block(stripped)
82
78
  end
79
+ end
83
80
 
84
- # Regular line — format and print
85
- $stdout.print format_line(line)
86
- $stdout.flush
81
+ def open_code_block(stripped)
82
+ @in_code_block = true
83
+ @code_lang = stripped.match(/```(\w*)/)[1]
84
+ @code_lang = 'ruby' if @code_lang.empty?
85
+ @code_buffer = +''
86
+ $stdout.puts @pastel.dim(" ┌─ #{@code_lang}")
87
87
  end
88
88
 
89
89
  def render_code_block
90
90
  return if @code_buffer.empty?
91
91
 
92
- lexer = Rouge::Lexer.find(@code_lang || 'ruby') || Rouge::Lexers::PlainText.new
93
- highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
94
- border = @pastel.dim(' │ ')
95
-
96
- highlighted.each_line do |l|
97
- $stdout.print "#{border}#{l}"
98
- end
99
- $stdout.puts @pastel.dim(' └─')
100
- $stdout.flush
101
-
92
+ output_highlighted_code
102
93
  @code_buffer = +''
103
94
  rescue StandardError
104
- # Fallback: print unformatted
105
95
  @code_buffer.each_line { |l| $stdout.print " #{l}" }
106
96
  $stdout.puts
107
97
  @code_buffer = +''
108
98
  end
109
99
 
100
+ def output_highlighted_code
101
+ lexer = Rouge::Lexer.find(@code_lang || 'ruby') || Rouge::Lexers::PlainText.new
102
+ highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
103
+ border = @pastel.dim(' │ ')
104
+ highlighted.each_line { |l| $stdout.print "#{border}#{l}" }
105
+ $stdout.puts @pastel.dim(' └─')
106
+ $stdout.flush
107
+ end
108
+
110
109
  def format_line(line)
111
110
  stripped = line.rstrip
112
111
 
113
- # Headers
114
112
  case stripped
115
113
  when /\A\#{1,6}\s/
116
- level = stripped.match(/\A(\#{1,6})\s/)[1].length
117
- text = stripped.sub(/\A\#{1,6}\s+/, '')
118
- case level
119
- when 1 then "#{@pastel.bold.underline(text)}\n"
120
- when 2 then "\n#{@pastel.bold(text)}\n"
121
- else "#{@pastel.bold(text)}\n"
122
- end
123
- # Bullet lists
114
+ format_header(stripped)
124
115
  when /\A\s*[-*]\s/
125
- indent = stripped.match(/\A(\s*)/)[1]
126
- content = stripped.sub(/\A\s*[-*]\s+/, '')
127
- "#{indent} #{@pastel.cyan('•')} #{format_inline(content)}\n"
128
- # Numbered lists
116
+ format_bullet(stripped)
129
117
  when /\A\s*\d+\.\s/
130
- indent = stripped.match(/\A(\s*)/)[1]
131
- num = stripped.match(/(\d+)\./)[1]
132
- content = stripped.sub(/\A\s*\d+\.\s+/, '')
133
- "#{indent} #{@pastel.cyan("#{num}.")} #{format_inline(content)}\n"
134
- # Horizontal rules
118
+ format_numbered_item(stripped)
135
119
  when /\A-{3,}\z/
136
120
  "#{@pastel.dim('─' * 40)}\n"
137
121
  else
@@ -139,6 +123,29 @@ module RubynCode
139
123
  end
140
124
  end
141
125
 
126
+ def format_header(stripped)
127
+ level = stripped.match(/\A(\#{1,6})\s/)[1].length
128
+ text = stripped.sub(/\A\#{1,6}\s+/, '')
129
+ case level
130
+ when 1 then "#{@pastel.bold.underline(text)}\n"
131
+ when 2 then "\n#{@pastel.bold(text)}\n"
132
+ else "#{@pastel.bold(text)}\n"
133
+ end
134
+ end
135
+
136
+ def format_bullet(stripped)
137
+ indent = stripped.match(/\A(\s*)/)[1]
138
+ content = stripped.sub(/\A\s*[-*]\s+/, '')
139
+ "#{indent} #{@pastel.cyan('•')} #{format_inline(content)}\n"
140
+ end
141
+
142
+ def format_numbered_item(stripped)
143
+ indent = stripped.match(/\A(\s*)/)[1]
144
+ num = stripped.match(/(\d+)\./)[1]
145
+ content = stripped.sub(/\A\s*\d+\.\s+/, '')
146
+ "#{indent} #{@pastel.cyan("#{num}.")} #{format_inline(content)}\n"
147
+ end
148
+
142
149
  def format_inline(text)
143
150
  text
144
151
  .gsub(/\*\*(.+?)\*\*/) { @pastel.bold(Regexp.last_match(1)) }
@@ -50,6 +50,16 @@ module RubynCode
50
50
  return
51
51
  end
52
52
 
53
+ latest = fetch_latest_version
54
+ return unless latest
55
+
56
+ write_cache(latest)
57
+ @result = latest
58
+ rescue StandardError
59
+ # Silent — never interrupt startup for a version check
60
+ end
61
+
62
+ def fetch_latest_version
53
63
  conn = Faraday.new do |f|
54
64
  f.options.timeout = 5
55
65
  f.options.open_timeout = 3
@@ -57,16 +67,8 @@ module RubynCode
57
67
  response = conn.get(RUBYGEMS_API)
58
68
  return unless response.success?
59
69
 
60
- data = JSON.parse(response.body)
61
- latest = data['version']
62
- return unless latest
63
- return unless latest.match?(/\A\d+\.\d+/)
64
- return unless Gem::Version.correct?(latest)
65
-
66
- write_cache(latest)
67
- @result = latest
68
- rescue StandardError
69
- # Silent — never interrupt startup for a version check
70
+ latest = JSON.parse(response.body)['version']
71
+ latest if latest&.match?(/\A\d+\.\d+/) && Gem::Version.correct?(latest)
70
72
  end
71
73
 
72
74
  def newer?(remote, local)
@@ -79,11 +81,26 @@ module RubynCode
79
81
  return nil unless File.exist?(CACHE_FILE)
80
82
  return nil if (Time.now - File.mtime(CACHE_FILE)) > CACHE_TTL
81
83
 
82
- File.read(CACHE_FILE).strip
84
+ cached = File.read(CACHE_FILE).strip
85
+ return nil unless valid_version?(cached)
86
+
87
+ cached
83
88
  rescue StandardError
84
89
  nil
85
90
  end
86
91
 
92
+ def valid_version?(version)
93
+ return false unless version&.match?(/\A\d+\.\d+/)
94
+ return false unless Gem::Version.correct?(version)
95
+
96
+ # Sanity: remote shouldn't be more than 10 major versions ahead
97
+ remote = Gem::Version.new(version)
98
+ local = Gem::Version.new(RubynCode::VERSION)
99
+ (remote.segments.first - local.segments.first).abs < 10
100
+ rescue StandardError
101
+ false
102
+ end
103
+
87
104
  def write_cache(version)
88
105
  File.write(CACHE_FILE, version)
89
106
  rescue StandardError
@@ -10,6 +10,7 @@ module RubynCode
10
10
  SESSIONS_DIR = File.join(HOME_DIR, 'sessions')
11
11
  MEMORIES_DIR = File.join(HOME_DIR, 'memories')
12
12
 
13
+ DEFAULT_PROVIDER = 'anthropic'
13
14
  DEFAULT_MODEL = 'claude-opus-4-6'
14
15
  MAX_ITERATIONS = 200
15
16
  MAX_SUB_AGENT_ITERATIONS = 200
@@ -38,6 +39,15 @@ module RubynCode
38
39
  OAUTH_TOKEN_URL = 'https://claude.ai/oauth/token'
39
40
  OAUTH_SCOPES = 'user:read model:read model:write'
40
41
 
42
+ # Known provider configurations: provider name → { env_key:, base_url: (if not default) }
43
+ PROVIDER_ENV_KEYS = {
44
+ 'anthropic' => 'ANTHROPIC_API_KEY',
45
+ 'openai' => 'OPENAI_API_KEY',
46
+ 'groq' => 'GROQ_API_KEY',
47
+ 'together' => 'TOGETHER_API_KEY',
48
+ 'ollama' => 'OLLAMA_API_KEY'
49
+ }.freeze
50
+
41
51
  DANGEROUS_PATTERNS = [
42
52
  'rm -rf /', 'sudo rm', 'shutdown', 'reboot',
43
53
  '> /dev/', 'mkfs', 'dd if=', ':(){:|:&};:'
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+
6
+ module RubynCode
7
+ module Config
8
+ # Auto-generated project profile that caches detected project stack
9
+ # information. First session pays the detection cost; subsequent
10
+ # sessions load a compact ~500-token profile instead of re-exploring.
11
+ class ProjectProfile
12
+ PROFILE_FILENAME = 'project_profile.yml'
13
+
14
+ DETECTABLE_KEYS = %w[
15
+ framework ruby_version database test_framework
16
+ factories auth background_jobs api frontend
17
+ key_models service_pattern custom_conventions
18
+ ].freeze
19
+
20
+ attr_reader :data, :profile_path
21
+
22
+ def initialize(project_root:)
23
+ @project_root = File.expand_path(project_root)
24
+ @project_dir = File.join(@project_root, '.rubyn-code')
25
+ @profile_path = File.join(@project_dir, PROFILE_FILENAME)
26
+ @data = {}
27
+ end
28
+
29
+ # Load existing profile or return nil if none exists.
30
+ def load
31
+ return nil unless File.exist?(@profile_path)
32
+
33
+ raw = YAML.safe_load_file(@profile_path, permitted_classes: [Symbol])
34
+ @data = raw.is_a?(Hash) ? raw : {}
35
+ self
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # Detect project stack and save profile.
41
+ def detect_and_save!
42
+ @data = {}
43
+ detect_framework
44
+ detect_ruby_version
45
+ detect_database
46
+ detect_test_framework
47
+ detect_auth
48
+ detect_background_jobs
49
+ detect_api_framework
50
+ detect_key_models
51
+ detect_service_pattern
52
+ save!
53
+ self
54
+ end
55
+
56
+ # Load if exists, otherwise detect and save.
57
+ def load_or_detect!
58
+ load || detect_and_save!
59
+ end
60
+
61
+ # Compact string representation for system prompt injection (~500 tokens).
62
+ def to_prompt
63
+ return '' if @data.empty?
64
+
65
+ lines = ['Project Profile:']
66
+ @data.each do |key, value|
67
+ formatted = value.is_a?(Array) ? value.join(', ') : value.to_s
68
+ lines << " #{key}: #{formatted}" unless formatted.empty?
69
+ end
70
+ lines.join("\n")
71
+ end
72
+
73
+ # Save the current profile data to disk.
74
+ def save!
75
+ FileUtils.mkdir_p(@project_dir)
76
+ File.write(@profile_path, YAML.dump(@data))
77
+ end
78
+
79
+ # Check if the profile is stale (older than 7 days).
80
+ def stale?
81
+ return true unless File.exist?(@profile_path)
82
+
83
+ (Time.now - File.mtime(@profile_path)) > 604_800
84
+ end
85
+
86
+ FRAMEWORK_GEMS = { 'rails' => 'rails', 'sinatra' => 'sinatra', 'hanami' => 'hanami' }.freeze
87
+ API_GEMS = { 'grape' => 'grape', 'graphql' => 'graphql' }.freeze
88
+ FRONTEND_GEMS = { 'turbo-rails' => 'hotwire', 'react-rails' => 'react' }.freeze
89
+
90
+ private
91
+
92
+ def detect_framework
93
+ gemfile = read_file('Gemfile')
94
+ return unless gemfile
95
+
96
+ @data['framework'] = FRAMEWORK_GEMS.each_value.find { |gem| gemfile.include?(gem) } || 'ruby'
97
+ end
98
+
99
+ def detect_ruby_version
100
+ path = File.join(@project_root, '.ruby-version')
101
+ @data['ruby_version'] = File.read(path).strip if File.exist?(path)
102
+ end
103
+
104
+ def detect_database
105
+ gemfile = read_file('Gemfile')
106
+ return unless gemfile
107
+
108
+ @data['database'] = 'postgresql' if gemfile.include?('pg')
109
+ @data['database'] ||= 'mysql' if gemfile.include?('mysql2')
110
+ @data['database'] ||= 'sqlite' if gemfile.include?('sqlite3')
111
+ end
112
+
113
+ def detect_test_framework
114
+ gemfile = read_file('Gemfile')
115
+ return unless gemfile
116
+
117
+ @data['test_framework'] = 'rspec' if gemfile.match?(/['"]rspec['"]/)
118
+ @data['test_framework'] ||= 'minitest' if gemfile.include?('minitest')
119
+ @data['factories'] = 'factory_bot' if gemfile.include?('factory_bot')
120
+ end
121
+
122
+ def detect_auth
123
+ gemfile = read_file('Gemfile')
124
+ return unless gemfile
125
+
126
+ @data['auth'] = 'devise' if gemfile.include?('devise')
127
+ @data['auth'] ||= 'rodauth' if gemfile.include?('rodauth')
128
+ @data['auth'] ||= 'clearance' if gemfile.include?('clearance')
129
+ end
130
+
131
+ def detect_background_jobs
132
+ gemfile = read_file('Gemfile')
133
+ return unless gemfile
134
+
135
+ @data['background_jobs'] = 'sidekiq' if gemfile.include?('sidekiq')
136
+ @data['background_jobs'] ||= 'good_job' if gemfile.include?('good_job')
137
+ @data['background_jobs'] ||= 'solid_queue' if gemfile.include?('solid_queue')
138
+ end
139
+
140
+ def detect_api_framework
141
+ gemfile = read_file('Gemfile')
142
+ return unless gemfile
143
+
144
+ detect_gem_key(gemfile, 'api', API_GEMS)
145
+ detect_gem_key(gemfile, 'frontend', FRONTEND_GEMS)
146
+ end
147
+
148
+ def detect_gem_key(gemfile, key, gem_map)
149
+ gem_map.each do |gem_name, value|
150
+ next if @data[key]
151
+
152
+ @data[key] = value if gemfile.include?(gem_name)
153
+ end
154
+ end
155
+
156
+ def detect_key_models
157
+ model_dir = File.join(@project_root, 'app', 'models')
158
+ return unless File.directory?(model_dir)
159
+
160
+ models = Dir.glob(File.join(model_dir, '*.rb'))
161
+ .map { |f| File.basename(f, '.rb').split('_').map(&:capitalize).join }
162
+ .reject { |m| m == 'ApplicationRecord' }
163
+ .first(20)
164
+ @data['key_models'] = models unless models.empty?
165
+ end
166
+
167
+ def detect_service_pattern
168
+ service_dir = File.join(@project_root, 'app', 'services')
169
+ return unless File.directory?(service_dir)
170
+
171
+ @data['service_pattern'] = 'app/services/**/*_service.rb'
172
+ conventions = []
173
+ conventions << 'Service objects implement .call class method'
174
+ @data['custom_conventions'] = conventions
175
+ end
176
+
177
+ def read_file(relative_path)
178
+ path = File.join(@project_root, relative_path)
179
+ File.read(path) if File.exist?(path)
180
+ rescue StandardError
181
+ nil
182
+ end
183
+ end
184
+ end
185
+ end