rubyn-code 0.3.0 → 0.5.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 +263 -21
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +34 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
- data/lib/rubyn_code/agent/llm_caller.rb +11 -1
- data/lib/rubyn_code/agent/loop.rb +14 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
- data/lib/rubyn_code/agent/tool_processor.rb +25 -3
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/token_store.rb +50 -9
- data/lib/rubyn_code/autonomous/daemon.rb +117 -14
- data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
- data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
- data/lib/rubyn_code/cli/app.rb +116 -11
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
- data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +32 -2
- data/lib/rubyn_code/cli/commands/provider.rb +124 -0
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +54 -3
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/repl.rb +15 -0
- data/lib/rubyn_code/cli/repl_commands.rb +3 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +74 -1
- data/lib/rubyn_code/config/defaults.rb +3 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +12 -6
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +18 -2
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/manager.rb +37 -3
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +112 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +69 -2
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
- data/lib/rubyn_code/llm/client.rb +29 -4
- data/lib/rubyn_code/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
- data/lib/rubyn_code/output/diff_renderer.rb +3 -2
- data/lib/rubyn_code/self_test.rb +316 -0
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +76 -0
- data/lib/rubyn_code/skills/document.rb +8 -2
- data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/matcher.rb +89 -0
- data/lib/rubyn_code/skills/pack_context.rb +163 -0
- data/lib/rubyn_code/skills/pack_installer.rb +194 -0
- data/lib/rubyn_code/skills/pack_manager.rb +230 -0
- data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
- data/lib/rubyn_code/skills/registry_client.rb +241 -0
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/base.rb +13 -0
- data/lib/rubyn_code/tools/bash.rb +5 -0
- data/lib/rubyn_code/tools/edit_file.rb +62 -5
- data/lib/rubyn_code/tools/executor.rb +65 -8
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +7 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +9 -7
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +34 -0
- data/skills/rubyn_self_test.md +88 -1
- metadata +43 -1
|
@@ -18,8 +18,9 @@ module RubynCode
|
|
|
18
18
|
attr_accessor :model
|
|
19
19
|
|
|
20
20
|
def initialize(model: nil, provider: nil, adapter: nil)
|
|
21
|
-
|
|
22
|
-
@
|
|
21
|
+
settings = Config::Settings.new
|
|
22
|
+
@model = model || settings.model
|
|
23
|
+
@provider = provider || settings.provider
|
|
23
24
|
@adapter = adapter || resolve_adapter(@provider)
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -65,6 +66,22 @@ module RubynCode
|
|
|
65
66
|
|
|
66
67
|
private
|
|
67
68
|
|
|
69
|
+
def build_custom_adapter(provider, config, base_url, available_models)
|
|
70
|
+
case config.fetch('api_format', 'openai')
|
|
71
|
+
when 'anthropic'
|
|
72
|
+
Adapters::AnthropicCompatible.new(provider: provider, base_url: base_url, available_models: available_models)
|
|
73
|
+
else
|
|
74
|
+
Adapters::OpenAICompatible.new(provider: provider, base_url: base_url, available_models: available_models)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def extract_model_names(config)
|
|
79
|
+
raw = config&.dig('models')
|
|
80
|
+
return [] unless raw
|
|
81
|
+
|
|
82
|
+
raw.is_a?(Hash) ? raw.values : Array(raw)
|
|
83
|
+
end
|
|
84
|
+
|
|
68
85
|
# Builds the appropriate adapter for a given provider name.
|
|
69
86
|
def resolve_adapter(provider)
|
|
70
87
|
case provider
|
|
@@ -74,12 +91,20 @@ module RubynCode
|
|
|
74
91
|
config = Config::Settings.new.provider_config(provider)
|
|
75
92
|
base_url = config&.fetch('base_url', nil)
|
|
76
93
|
|
|
94
|
+
if config.nil?
|
|
95
|
+
raise ConfigError,
|
|
96
|
+
"Unknown provider '#{provider}'. " \
|
|
97
|
+
"Add it to config.yml under providers.#{provider} with base_url, env_key, and models."
|
|
98
|
+
end
|
|
99
|
+
|
|
77
100
|
unless base_url
|
|
78
101
|
raise ConfigError,
|
|
79
|
-
"
|
|
102
|
+
"Provider '#{provider}' is missing base_url in config.yml. " \
|
|
103
|
+
"Add base_url under providers.#{provider} (e.g., base_url: https://api.#{provider}.com/v1)"
|
|
80
104
|
end
|
|
81
105
|
|
|
82
|
-
|
|
106
|
+
available_models = extract_model_names(config)
|
|
107
|
+
build_custom_adapter(provider, config, base_url, available_models)
|
|
83
108
|
end
|
|
84
109
|
end
|
|
85
110
|
end
|
|
@@ -84,7 +84,8 @@ module RubynCode
|
|
|
84
84
|
# @param task_type [Symbol]
|
|
85
85
|
# @param client [LLM::Client, nil] active client (for provider checks)
|
|
86
86
|
# @return [Hash] { provider:, model: }
|
|
87
|
-
|
|
87
|
+
# -- multi-source fallback chain
|
|
88
|
+
def resolve(task_type, client: nil)
|
|
88
89
|
tier = tier_for(task_type)
|
|
89
90
|
active = active_provider
|
|
90
91
|
|
|
@@ -49,7 +49,8 @@ module RubynCode
|
|
|
49
49
|
def parse_servers(servers)
|
|
50
50
|
servers.map do |name, server_def|
|
|
51
51
|
{ name: name, command: server_def['command'],
|
|
52
|
-
args: Array(server_def['args']), env: expand_env(server_def['env'] || {})
|
|
52
|
+
args: Array(server_def['args']), env: expand_env(server_def['env'] || {}),
|
|
53
|
+
url: server_def['url'], timeout: server_def['timeout'] }
|
|
53
54
|
end
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -51,7 +51,7 @@ module RubynCode
|
|
|
51
51
|
klass
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/
|
|
54
|
+
def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/MethodLength -- dynamic class creation requires setting many constants
|
|
55
55
|
bridge = self
|
|
56
56
|
|
|
57
57
|
Class.new(Tools::Base) do
|
|
@@ -80,7 +80,8 @@ module RubynCode
|
|
|
80
80
|
}
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
# -- assembles multi-field summary
|
|
84
|
+
def build_session_summary_lines(session_id, turns, totals)
|
|
84
85
|
avg_cost = turns.positive? ? totals[:cost] / turns : 0.0
|
|
85
86
|
[
|
|
86
87
|
header('Session Summary'),
|
|
@@ -104,7 +105,8 @@ module RubynCode
|
|
|
104
105
|
).to_a
|
|
105
106
|
end
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
# -- assembles multi-field daily summary
|
|
109
|
+
def build_daily_summary_lines(today, rows)
|
|
108
110
|
total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
|
|
109
111
|
total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
|
|
110
112
|
total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }
|
|
@@ -97,7 +97,8 @@ module RubynCode
|
|
|
97
97
|
table
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
# -- LCS algorithm step
|
|
101
|
+
def fill_lcs_row(table, row, old_lines, new_lines, col_count)
|
|
101
102
|
(1..col_count).each do |col|
|
|
102
103
|
table[row][col] = if old_lines[row - 1] == new_lines[col - 1]
|
|
103
104
|
table[row - 1][col - 1] + 1
|
|
@@ -121,7 +122,7 @@ module RubynCode
|
|
|
121
122
|
result
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/
|
|
125
|
+
def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/ParameterLists -- LCS backtrack step requires all state
|
|
125
126
|
if lines_match?(old_lines, new_lines, old_idx, new_idx)
|
|
126
127
|
result.unshift([:equal, old_idx - 1, new_idx - 1])
|
|
127
128
|
[old_idx - 1, new_idx - 1]
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
# Programmatic smoke test that exercises every major subsystem.
|
|
7
|
+
# Mirrors the checks in skills/rubyn_self_test.md but runs without
|
|
8
|
+
# the REPL or an LLM — suitable for CI and rake tasks.
|
|
9
|
+
#
|
|
10
|
+
# rubocop:disable Metrics/ClassLength -- intentionally comprehensive
|
|
11
|
+
class SelfTest
|
|
12
|
+
Result = Data.define(:name, :passed, :detail)
|
|
13
|
+
|
|
14
|
+
attr_reader :results, :project_root
|
|
15
|
+
|
|
16
|
+
def initialize(project_root: Dir.pwd)
|
|
17
|
+
@project_root = project_root
|
|
18
|
+
@results = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run! # rubocop:disable Naming/PredicateMethod -- returns bool but primary purpose is side effects
|
|
22
|
+
@results = []
|
|
23
|
+
|
|
24
|
+
check_file_operations
|
|
25
|
+
check_search
|
|
26
|
+
check_bash
|
|
27
|
+
check_git
|
|
28
|
+
check_specs
|
|
29
|
+
check_compressor_strategies
|
|
30
|
+
check_skills
|
|
31
|
+
check_config
|
|
32
|
+
check_codebase_index
|
|
33
|
+
check_slash_commands
|
|
34
|
+
check_architecture
|
|
35
|
+
|
|
36
|
+
print_scorecard
|
|
37
|
+
results.all?(&:passed)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# ── 1. File Operations ──────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def check_file_operations
|
|
45
|
+
check_file_read
|
|
46
|
+
check_file_write_edit_cleanup
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
record('File operations', false, e.message)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def check_file_read
|
|
52
|
+
content = File.read(File.join(project_root, 'lib/rubyn_code/version.rb'))
|
|
53
|
+
record('File read (version.rb)', content.include?('VERSION ='))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# -- sequential file ops
|
|
57
|
+
def check_file_write_edit_cleanup
|
|
58
|
+
tmp = File.join(project_root, '.rubyn-code/self_test_tmp.rb')
|
|
59
|
+
FileUtils.mkdir_p(File.dirname(tmp))
|
|
60
|
+
|
|
61
|
+
File.write(tmp, '# self-test')
|
|
62
|
+
record('File write (tmp)', File.exist?(tmp))
|
|
63
|
+
|
|
64
|
+
File.write(tmp, File.read(tmp).sub('# self-test', '# self-test passed'))
|
|
65
|
+
record('File edit (tmp)', File.read(tmp).include?('# self-test passed'))
|
|
66
|
+
|
|
67
|
+
File.delete(tmp)
|
|
68
|
+
record('File cleanup (tmp)', !File.exist?(tmp))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ── 2. Search ───────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def check_search
|
|
74
|
+
rb_files = Dir.glob(File.join(project_root, 'lib/**/*.rb'))
|
|
75
|
+
record('Glob lib/**/*.rb', rb_files.size >= 50, "#{rb_files.size} files")
|
|
76
|
+
|
|
77
|
+
base_classes = rb_files.count { |f| File.read(f).match?(/class.*Base/) }
|
|
78
|
+
record('Grep class.*Base', base_classes >= 3, "#{base_classes} matches")
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
record('Search', false, e.message)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ── 3. Bash ─────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def check_bash
|
|
86
|
+
ruby_v = `ruby --version 2>&1`.strip
|
|
87
|
+
record('Bash: ruby --version', ruby_v.include?('ruby'), ruby_v)
|
|
88
|
+
|
|
89
|
+
rubocop_v = `bundle exec rubocop --version 2>&1`.strip
|
|
90
|
+
record('Bash: rubocop --version', rubocop_v.match?(/\d+\.\d+/), rubocop_v)
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
record('Bash', false, e.message)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ── 4. Git ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def check_git
|
|
98
|
+
run_cmd('git status --short')
|
|
99
|
+
record('Git status', true)
|
|
100
|
+
|
|
101
|
+
log = run_cmd('git log --oneline -3')
|
|
102
|
+
record('Git log', log.match?(/^[0-9a-f]+/), log.lines.first&.strip)
|
|
103
|
+
|
|
104
|
+
run_cmd('git diff --stat')
|
|
105
|
+
record('Git diff', true)
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
record('Git', false, e.message)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# ── 5. Specs ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def check_specs
|
|
113
|
+
%w[
|
|
114
|
+
spec/rubyn_code/tools/output_compressor_spec.rb
|
|
115
|
+
spec/rubyn_code/llm/model_router_spec.rb
|
|
116
|
+
].each { |spec| run_single_spec(spec) }
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
record('Specs', false, e.message)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def run_single_spec(spec)
|
|
122
|
+
path = File.join(project_root, spec)
|
|
123
|
+
unless File.exist?(path)
|
|
124
|
+
record("RSpec: #{File.basename(spec)}", false, 'file not found')
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
output = run_cmd("bundle exec rspec #{path} --format progress 2>&1")
|
|
128
|
+
record("RSpec: #{File.basename(spec)}", output.include?('0 failures'))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ── 6. Output Compressor ────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def check_compressor_strategies
|
|
134
|
+
compressor = Tools::OutputCompressor.new
|
|
135
|
+
verified = 0
|
|
136
|
+
|
|
137
|
+
verified += verify_head_tail(compressor)
|
|
138
|
+
verified += verify_spec_summary(compressor)
|
|
139
|
+
verified += verify_top_matches(compressor)
|
|
140
|
+
verified += verify_tree_collapse(compressor)
|
|
141
|
+
verified += verify_diff_hunks(compressor)
|
|
142
|
+
|
|
143
|
+
record('Compression strategies verified', verified >= 3, "#{verified}/5 active")
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
record('Output compressor', false, e.message)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def verify_head_tail(compressor)
|
|
149
|
+
big = (1..5000).to_a.join("\n")
|
|
150
|
+
compressed = compressor.compress('bash', big)
|
|
151
|
+
pass = compressed.length < big.length
|
|
152
|
+
record('Compressor: head_tail', pass)
|
|
153
|
+
pass ? 1 : 0
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def verify_spec_summary(compressor)
|
|
157
|
+
spec_out = run_cmd(
|
|
158
|
+
'bundle exec rspec spec/rubyn_code/tools/base_spec.rb --format documentation 2>&1'
|
|
159
|
+
)
|
|
160
|
+
compressed = compressor.compress('run_specs', spec_out)
|
|
161
|
+
pass = compressed.length < spec_out.length || compressed.include?('0 failures')
|
|
162
|
+
record('Compressor: spec_summary', pass)
|
|
163
|
+
pass ? 1 : 0
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def verify_top_matches(compressor)
|
|
167
|
+
grep_out = rb_files_with_def.join("\n")
|
|
168
|
+
compressed = compressor.compress('grep', grep_out)
|
|
169
|
+
pass = compressed.length <= grep_out.length
|
|
170
|
+
record('Compressor: top_matches', pass)
|
|
171
|
+
pass ? 1 : 0
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def verify_tree_collapse(compressor)
|
|
175
|
+
all_rb = Dir.glob(File.join(project_root, '**/*.rb')).join("\n")
|
|
176
|
+
compressed = compressor.compress('glob', all_rb)
|
|
177
|
+
pass = compressed.length <= all_rb.length
|
|
178
|
+
record('Compressor: tree_collapse', pass)
|
|
179
|
+
pass ? 1 : 0
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def verify_diff_hunks(compressor)
|
|
183
|
+
diff = run_cmd('git log --oneline -1 --format=%H | xargs -I{} git diff {}~5..{} 2>/dev/null')
|
|
184
|
+
if diff.strip.empty?
|
|
185
|
+
record('Compressor: diff_hunks', true, 'SKIP — diff too small')
|
|
186
|
+
return 0
|
|
187
|
+
end
|
|
188
|
+
pass = compressor.compress('git_diff', diff).length <= diff.length
|
|
189
|
+
record('Compressor: diff_hunks', pass)
|
|
190
|
+
pass ? 1 : 0
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ── 7. Skills ───────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def check_skills
|
|
196
|
+
catalog = Skills::Catalog.new(project_root)
|
|
197
|
+
skills = catalog.available
|
|
198
|
+
record('Skills catalog', skills.size >= 10, "#{skills.size} skills")
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
record('Skills', false, e.message)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# ── 8. Config ───────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def check_config
|
|
206
|
+
config_path = File.expand_path('~/.rubyn-code/config.yml')
|
|
207
|
+
if File.exist?(config_path)
|
|
208
|
+
record('Config (config.yml)', File.read(config_path).include?('provider'))
|
|
209
|
+
else
|
|
210
|
+
record('Config (config.yml)', false, 'not found')
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
profile = File.join(project_root, '.rubyn-code/project_profile.yml')
|
|
214
|
+
record('Config (project_profile)', File.exist?(profile),
|
|
215
|
+
File.exist?(profile) ? 'exists' : 'SKIP — first session')
|
|
216
|
+
rescue StandardError => e
|
|
217
|
+
record('Config', false, e.message)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ── 9. Codebase Index ───────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def check_codebase_index
|
|
223
|
+
path = File.join(project_root, '.rubyn-code/codebase_index.json')
|
|
224
|
+
record('Codebase index', File.exist?(path),
|
|
225
|
+
File.exist?(path) ? 'exists' : 'SKIP — first session')
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
record('Codebase index', false, e.message)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# ── 10. Slash Commands ──────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def check_slash_commands
|
|
233
|
+
cmd_dir = File.join(project_root, 'lib/rubyn_code/cli/commands')
|
|
234
|
+
infra = %w[base.rb context.rb registry.rb]
|
|
235
|
+
cmds = Dir.glob(File.join(cmd_dir, '*.rb')).reject { |f| infra.include?(File.basename(f)) }
|
|
236
|
+
record('Slash commands', cmds.size >= 15, "#{cmds.size} commands")
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
record('Slash commands', false, e.message)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# ── 11. Architecture ────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def check_architecture
|
|
244
|
+
check_autoloads
|
|
245
|
+
check_layer_dirs
|
|
246
|
+
check_core_modules
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
record('Architecture', false, e.message)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def check_autoloads
|
|
252
|
+
content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
|
|
253
|
+
autoloads = content.scan('autoload').size
|
|
254
|
+
record('Autoload entries', autoloads >= 40, "#{autoloads} entries")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def check_layer_dirs
|
|
258
|
+
dirs = Dir.glob(File.join(project_root, 'lib/rubyn_code/*/'))
|
|
259
|
+
record('Layer directories', dirs.size >= 14, "#{dirs.size} dirs")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def check_core_modules
|
|
263
|
+
content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
|
|
264
|
+
core = %w[Agent Tools Context Skills Memory Observability Learning]
|
|
265
|
+
found = core.select { |m| content.include?("module #{m}") }
|
|
266
|
+
record('Core modules', found.size == core.size, "#{found.size}/#{core.size}")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# ── Helpers ─────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
def record(name, passed, detail = nil)
|
|
272
|
+
@results << Result.new(name: name, passed: passed, detail: detail)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def run_cmd(cmd)
|
|
276
|
+
`cd #{project_root} && #{cmd} 2>&1`.strip
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def rb_files_with_def
|
|
280
|
+
Dir.glob(File.join(project_root, 'lib/**/*.rb')).flat_map do |f|
|
|
281
|
+
File.readlines(f).select { |l| l.include?('def ') }.map { |l| "#{f}:#{l.strip}" }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def print_scorecard
|
|
286
|
+
puts
|
|
287
|
+
puts 'Rubyn Self-Test Results'
|
|
288
|
+
puts '=' * 50
|
|
289
|
+
results.each_with_index { |r, i| print_result(r, i + 1) }
|
|
290
|
+
print_summary
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def print_result(result, num)
|
|
294
|
+
icon = result.passed ? "\e[32m✅\e[0m" : "\e[31m❌\e[0m"
|
|
295
|
+
suffix = result.detail ? " — #{result.detail}" : ''
|
|
296
|
+
puts format(' %2<num>d. %<icon>s %<name>s%<suffix>s',
|
|
297
|
+
num: num, icon: icon, name: result.name, suffix: suffix)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def print_summary
|
|
301
|
+
passed = results.count(&:passed)
|
|
302
|
+
total = results.size
|
|
303
|
+
pct = total.positive? ? (passed * 100.0 / total).round : 0
|
|
304
|
+
failed = total - passed
|
|
305
|
+
|
|
306
|
+
puts '=' * 50
|
|
307
|
+
if failed.zero?
|
|
308
|
+
puts "\e[32mScore: #{passed}/#{total} (#{pct}%) — All systems go!\e[0m"
|
|
309
|
+
else
|
|
310
|
+
puts "\e[33mScore: #{passed}/#{total} (#{pct}%) — #{failed} failures\e[0m"
|
|
311
|
+
end
|
|
312
|
+
puts
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
# rubocop:enable Metrics/ClassLength
|
|
316
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'gemfile_parser'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Skills
|
|
8
|
+
# Suggests skill packs based on gems detected in the project's Gemfile.
|
|
9
|
+
#
|
|
10
|
+
# On session start, parses the Gemfile, queries the registry for matching
|
|
11
|
+
# packs, and shows a one-time suggestion. Tracks shown suggestions in
|
|
12
|
+
# `.rubyn-code/suggested.json` to avoid repeating.
|
|
13
|
+
class AutoSuggest
|
|
14
|
+
SUGGESTED_FILE = 'suggested.json'
|
|
15
|
+
|
|
16
|
+
# @param project_root [String]
|
|
17
|
+
# @param registry_client [RegistryClient]
|
|
18
|
+
def initialize(project_root:, registry_client: nil)
|
|
19
|
+
@project_root = project_root
|
|
20
|
+
@client = registry_client || RegistryClient.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check for suggestable packs and return a display message if any.
|
|
24
|
+
# Returns nil if no suggestions or if all have been shown/dismissed.
|
|
25
|
+
#
|
|
26
|
+
# This method never raises — registry failures are silently swallowed
|
|
27
|
+
# to avoid blocking session start.
|
|
28
|
+
#
|
|
29
|
+
# @return [String, nil] suggestion message or nil
|
|
30
|
+
def check
|
|
31
|
+
gems = parse_gemfile
|
|
32
|
+
return nil if gems.empty?
|
|
33
|
+
|
|
34
|
+
suggestions = fetch_suggestions(gems)
|
|
35
|
+
return nil if suggestions.empty?
|
|
36
|
+
|
|
37
|
+
new_suggestions = filter_shown(suggestions)
|
|
38
|
+
return nil if new_suggestions.empty?
|
|
39
|
+
|
|
40
|
+
record_shown(new_suggestions)
|
|
41
|
+
format_message(new_suggestions)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Mark a pack as installed so it won't be suggested again.
|
|
47
|
+
#
|
|
48
|
+
# @param name [String] pack name
|
|
49
|
+
def mark_installed(name)
|
|
50
|
+
state = load_state
|
|
51
|
+
state['installed'] ||= []
|
|
52
|
+
state['installed'] << name unless state['installed'].include?(name)
|
|
53
|
+
save_state(state)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Mark a suggestion as dismissed.
|
|
57
|
+
#
|
|
58
|
+
# @param name [String] pack name
|
|
59
|
+
def mark_dismissed(name)
|
|
60
|
+
state = load_state
|
|
61
|
+
state['dismissed'] ||= []
|
|
62
|
+
state['dismissed'] << name unless state['dismissed'].include?(name)
|
|
63
|
+
save_state(state)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def parse_gemfile
|
|
69
|
+
gemfile_path = File.join(@project_root, 'Gemfile')
|
|
70
|
+
return [] unless File.exist?(gemfile_path)
|
|
71
|
+
|
|
72
|
+
GemfileParser.gems(File.read(gemfile_path))
|
|
73
|
+
rescue StandardError
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch_suggestions(gems)
|
|
78
|
+
@client.fetch_suggestions(gems)
|
|
79
|
+
rescue RegistryError
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def filter_shown(suggestions)
|
|
84
|
+
state = load_state
|
|
85
|
+
shown = Array(state['shown'])
|
|
86
|
+
installed = Array(state['installed'])
|
|
87
|
+
dismissed = Array(state['dismissed'])
|
|
88
|
+
skip = (shown + installed + dismissed).uniq
|
|
89
|
+
|
|
90
|
+
suggestions.reject { |s| skip.include?(s['name']) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def record_shown(suggestions)
|
|
94
|
+
state = load_state
|
|
95
|
+
state['shown'] ||= []
|
|
96
|
+
suggestions.each do |s|
|
|
97
|
+
state['shown'] << s['name'] unless state['shown'].include?(s['name'])
|
|
98
|
+
end
|
|
99
|
+
save_state(state)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def format_message(suggestions)
|
|
103
|
+
gem_names = suggestions.map { |s| s['name'] }.join(', ')
|
|
104
|
+
details = suggestions.map { |s| "#{s['name']} (#{s['reason']})" }.join(', ')
|
|
105
|
+
install_cmd = "/install-skills #{suggestions.map { |s| s['name'] }.join(' ')}"
|
|
106
|
+
|
|
107
|
+
"Skill packs available: #{details}\n" \
|
|
108
|
+
"Run #{install_cmd} to install."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def load_state
|
|
112
|
+
path = state_path
|
|
113
|
+
return {} unless File.exist?(path)
|
|
114
|
+
|
|
115
|
+
JSON.parse(File.read(path))
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def save_state(state)
|
|
121
|
+
dir = File.dirname(state_path)
|
|
122
|
+
FileUtils.mkdir_p(dir)
|
|
123
|
+
File.write(state_path, JSON.pretty_generate(state))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def state_path
|
|
127
|
+
File.join(@project_root, '.rubyn-code', SUGGESTED_FILE)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -24,11 +24,60 @@ module RubynCode
|
|
|
24
24
|
@index
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Force the index to be rebuilt on next access. Used after installing
|
|
28
|
+
# a skill pack so newly-written files become discoverable in the same
|
|
29
|
+
# session.
|
|
30
|
+
def refresh!
|
|
31
|
+
@index = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def list
|
|
35
|
+
available.map { |e| e[:name] }
|
|
36
|
+
end
|
|
37
|
+
|
|
27
38
|
def find(name)
|
|
28
39
|
entry = available.find { |e| e[:name] == name.to_s }
|
|
29
40
|
entry&.fetch(:path)
|
|
30
41
|
end
|
|
31
42
|
|
|
43
|
+
# Search skill content — matches against names, descriptions, and tags.
|
|
44
|
+
# Returns matching entries sorted by relevance (number of field matches).
|
|
45
|
+
#
|
|
46
|
+
# @param term [String] search term (case-insensitive)
|
|
47
|
+
# @return [Array<Hash>] matching entries with :name, :description, :path, :relevance
|
|
48
|
+
def search(term)
|
|
49
|
+
pattern = /#{Regexp.escape(term)}/i
|
|
50
|
+
matches = available.filter_map do |entry|
|
|
51
|
+
relevance = compute_relevance(entry, pattern)
|
|
52
|
+
next if relevance.zero?
|
|
53
|
+
|
|
54
|
+
entry.merge(relevance: relevance)
|
|
55
|
+
end
|
|
56
|
+
matches.sort_by { |e| -e[:relevance] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Filter skills by category (subdirectory name).
|
|
60
|
+
# Skills are organized in subdirectories under each skills_dir.
|
|
61
|
+
#
|
|
62
|
+
# @param category [String] category/directory name (e.g. "rails", "testing")
|
|
63
|
+
# @return [Array<Hash>] matching entries
|
|
64
|
+
def by_category(category)
|
|
65
|
+
normalized = category.to_s.downcase
|
|
66
|
+
available.select do |entry|
|
|
67
|
+
path_category(entry[:path]).downcase == normalized
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Return the list of unique categories derived from skill file paths.
|
|
72
|
+
#
|
|
73
|
+
# @return [Array<String>] sorted category names
|
|
74
|
+
def categories
|
|
75
|
+
available.map { |e| path_category(e[:path]) }
|
|
76
|
+
.reject(&:empty?)
|
|
77
|
+
.uniq
|
|
78
|
+
.sort
|
|
79
|
+
end
|
|
80
|
+
|
|
32
81
|
private
|
|
33
82
|
|
|
34
83
|
def build_index
|
|
@@ -60,11 +109,38 @@ module RubynCode
|
|
|
60
109
|
{
|
|
61
110
|
name: name,
|
|
62
111
|
description: doc.description,
|
|
112
|
+
tags: doc.tags,
|
|
113
|
+
triggers: doc.triggers,
|
|
114
|
+
gems: doc.gems,
|
|
115
|
+
rails: doc.rails,
|
|
63
116
|
path: File.expand_path(path)
|
|
64
117
|
}
|
|
65
118
|
rescue StandardError
|
|
66
119
|
nil
|
|
67
120
|
end
|
|
121
|
+
|
|
122
|
+
def compute_relevance(entry, pattern)
|
|
123
|
+
score = 0
|
|
124
|
+
score += 3 if entry[:name].to_s.match?(pattern)
|
|
125
|
+
score += 2 if entry[:description].to_s.match?(pattern)
|
|
126
|
+
Array(entry[:tags]).each { |tag| score += 1 if tag.to_s.match?(pattern) }
|
|
127
|
+
score
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Derive a category from the skill file path.
|
|
131
|
+
# The category is the immediate parent directory name relative to one of
|
|
132
|
+
# the skills_dirs. Skills at the top level of a skills_dir have no category.
|
|
133
|
+
def path_category(path)
|
|
134
|
+
skills_dirs.each do |dir|
|
|
135
|
+
expanded = File.expand_path(dir)
|
|
136
|
+
next unless path.start_with?(expanded)
|
|
137
|
+
|
|
138
|
+
relative = path.delete_prefix("#{expanded}/")
|
|
139
|
+
parts = relative.split('/')
|
|
140
|
+
return parts.size > 1 ? parts.first : ''
|
|
141
|
+
end
|
|
142
|
+
''
|
|
143
|
+
end
|
|
68
144
|
end
|
|
69
145
|
end
|
|
70
146
|
end
|