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.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- 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
|
data/lib/rubyn_code/cli/setup.rb
CHANGED
|
@@ -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 #{
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
latest
|
|
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
|