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
|
@@ -22,6 +22,12 @@ module RubynCode
|
|
|
22
22
|
RISK_LEVEL = :write
|
|
23
23
|
REQUIRES_CONFIRMATION = false
|
|
24
24
|
|
|
25
|
+
# Take the first line of the tool's output, which is already formatted
|
|
26
|
+
# as "Edited /path.rb (N replacements)".
|
|
27
|
+
def self.summarize(output, _args)
|
|
28
|
+
output.to_s.lines.first.to_s.chomp[0, 200]
|
|
29
|
+
end
|
|
30
|
+
|
|
25
31
|
def execute(path:, old_text:, new_text:, replace_all: false)
|
|
26
32
|
resolved = read_file_safely(path)
|
|
27
33
|
content = File.read(resolved)
|
|
@@ -34,12 +40,34 @@ module RubynCode
|
|
|
34
40
|
format_diff_result(path, content, old_text, new_text, replace_all)
|
|
35
41
|
end
|
|
36
42
|
|
|
43
|
+
# Compute the proposed file content without writing to disk.
|
|
44
|
+
# Used by IDE mode to preview the edit in a diff view before the user
|
|
45
|
+
# accepts. Raises if old_text is missing or ambiguous, same as execute.
|
|
46
|
+
#
|
|
47
|
+
# @return [Hash] { content: String, type: 'modify' }
|
|
48
|
+
def preview_content(path:, old_text:, new_text:, replace_all: false)
|
|
49
|
+
resolved = read_file_safely(path)
|
|
50
|
+
content = File.read(resolved)
|
|
51
|
+
|
|
52
|
+
validate_occurrences!(path, content, old_text, replace_all)
|
|
53
|
+
|
|
54
|
+
{ content: apply_replacement(content, old_text, new_text, replace_all), type: 'modify' }
|
|
55
|
+
end
|
|
56
|
+
|
|
37
57
|
private
|
|
38
58
|
|
|
39
59
|
def validate_occurrences!(path, content, old_text, replace_all)
|
|
40
60
|
count = content.scan(old_text).length
|
|
41
61
|
|
|
42
|
-
|
|
62
|
+
# If exact match fails, try with normalized trailing whitespace on
|
|
63
|
+
# each line. Models sometimes strip or add trailing spaces/tabs.
|
|
64
|
+
if count.zero?
|
|
65
|
+
normalized_content = normalize_trailing_ws(content)
|
|
66
|
+
normalized_old = normalize_trailing_ws(old_text)
|
|
67
|
+
count = normalized_content.scan(normalized_old).length
|
|
68
|
+
|
|
69
|
+
raise Error, "old_text not found in #{path}. No changes made." if count.zero?
|
|
70
|
+
end
|
|
43
71
|
|
|
44
72
|
return if replace_all || count == 1
|
|
45
73
|
|
|
@@ -49,7 +77,28 @@ module RubynCode
|
|
|
49
77
|
end
|
|
50
78
|
|
|
51
79
|
def apply_replacement(content, old_text, new_text, replace_all)
|
|
52
|
-
|
|
80
|
+
# Try exact match first
|
|
81
|
+
if content.include?(old_text)
|
|
82
|
+
return replace_all ? content.gsub(old_text, new_text) : content.sub(old_text, new_text)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Fall back to normalized trailing-whitespace match
|
|
86
|
+
normalized_content = normalize_trailing_ws(content)
|
|
87
|
+
normalized_old = normalize_trailing_ws(old_text)
|
|
88
|
+
|
|
89
|
+
if normalized_content.include?(normalized_old)
|
|
90
|
+
if replace_all
|
|
91
|
+
normalized_content.gsub(normalized_old, new_text)
|
|
92
|
+
else
|
|
93
|
+
normalized_content.sub(normalized_old, new_text)
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
content.sub(old_text, new_text)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def normalize_trailing_ws(str)
|
|
101
|
+
str.gsub(/[^\S\n]+$/, '')
|
|
53
102
|
end
|
|
54
103
|
|
|
55
104
|
CONTEXT_LINES = 3 # rubocop:disable Lint/UselessConstantScoping
|
|
@@ -76,7 +125,7 @@ module RubynCode
|
|
|
76
125
|
end
|
|
77
126
|
|
|
78
127
|
def context_before(content, text)
|
|
79
|
-
idx = content
|
|
128
|
+
idx = find_index(content, text)
|
|
80
129
|
return [] unless idx
|
|
81
130
|
|
|
82
131
|
before = content[0...idx].lines.last(CONTEXT_LINES)
|
|
@@ -84,7 +133,7 @@ module RubynCode
|
|
|
84
133
|
end
|
|
85
134
|
|
|
86
135
|
def context_after(content, text)
|
|
87
|
-
idx = content
|
|
136
|
+
idx = find_index(content, text)
|
|
88
137
|
return [] unless idx
|
|
89
138
|
|
|
90
139
|
after_start = idx + text.length
|
|
@@ -93,11 +142,19 @@ module RubynCode
|
|
|
93
142
|
end
|
|
94
143
|
|
|
95
144
|
def find_line_number(content, text)
|
|
96
|
-
idx = content
|
|
145
|
+
idx = find_index(content, text)
|
|
97
146
|
return nil unless idx
|
|
98
147
|
|
|
99
148
|
content[0...idx].count("\n") + 1
|
|
100
149
|
end
|
|
150
|
+
|
|
151
|
+
# Find the index of text in content, falling back to normalized match.
|
|
152
|
+
def find_index(content, text)
|
|
153
|
+
idx = content.index(text)
|
|
154
|
+
return idx if idx
|
|
155
|
+
|
|
156
|
+
normalize_trailing_ws(content).index(normalize_trailing_ws(text))
|
|
157
|
+
end
|
|
101
158
|
end
|
|
102
159
|
|
|
103
160
|
Registry.register(EditFile)
|
|
@@ -4,17 +4,20 @@ module RubynCode
|
|
|
4
4
|
module Tools
|
|
5
5
|
class Executor
|
|
6
6
|
attr_reader :project_root, :output_compressor, :file_cache
|
|
7
|
-
attr_accessor :llm_client, :background_worker, :on_agent_status, :db, :ask_user_callback
|
|
7
|
+
attr_accessor :llm_client, :background_worker, :on_agent_status, :db, :ask_user_callback,
|
|
8
|
+
:codebase_index, :ide_client
|
|
8
9
|
|
|
9
|
-
def initialize(project_root:)
|
|
10
|
+
def initialize(project_root:, ide_client: nil)
|
|
10
11
|
@project_root = File.expand_path(project_root)
|
|
12
|
+
@ide_client = ide_client
|
|
11
13
|
@injections = {}
|
|
12
14
|
@output_compressor = OutputCompressor.new
|
|
13
15
|
@file_cache = FileCache.new
|
|
14
16
|
Registry.load_all!
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
# -- maps tool errors to results
|
|
20
|
+
def execute(tool_name, params)
|
|
18
21
|
# File cache intercept: serve cached reads, invalidate on writes
|
|
19
22
|
cached = try_file_cache(tool_name, params)
|
|
20
23
|
return cached if cached
|
|
@@ -23,6 +26,7 @@ module RubynCode
|
|
|
23
26
|
filtered = filter_params(tool, params)
|
|
24
27
|
raw = tool.truncate(tool.execute(**filtered).to_s)
|
|
25
28
|
update_file_cache(tool_name, filtered, raw)
|
|
29
|
+
maybe_update_codebase_index(tool_name, filtered)
|
|
26
30
|
@output_compressor.compress(tool_name, raw)
|
|
27
31
|
rescue ToolNotFoundError => e
|
|
28
32
|
error_result("Tool error: #{e.message}")
|
|
@@ -40,11 +44,24 @@ module RubynCode
|
|
|
40
44
|
Registry.tool_definitions
|
|
41
45
|
end
|
|
42
46
|
|
|
47
|
+
# Patterns that indicate a bash command writes to a file.
|
|
48
|
+
BASH_WRITE_PATTERNS = [
|
|
49
|
+
/(?:>>?)\s*(\S+)/, # > file or >> file
|
|
50
|
+
/\btee\s+(?:-a\s+)?(\S+)/, # tee file or tee -a file
|
|
51
|
+
/\bsed\s+-i\S*\s+.*\s(\S+)$/, # sed -i 's/...' file
|
|
52
|
+
/\bsed\s+-i\S*\s+.*\s(\S+)\s/ # sed -i 's/...' file (mid-command)
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
43
55
|
private
|
|
44
56
|
|
|
45
57
|
def build_tool(tool_name)
|
|
46
58
|
tool_class = Registry.get(tool_name)
|
|
47
|
-
|
|
59
|
+
# IDE-aware tools accept an ide_client parameter.
|
|
60
|
+
if @ide_client && tool_class.method(:new).parameters.any? { |_, name| name == :ide_client }
|
|
61
|
+
tool = tool_class.new(project_root: project_root, ide_client: @ide_client)
|
|
62
|
+
else
|
|
63
|
+
tool = tool_class.new(project_root: project_root)
|
|
64
|
+
end
|
|
48
65
|
inject_dependencies(tool, tool_name)
|
|
49
66
|
tool
|
|
50
67
|
end
|
|
@@ -57,7 +74,8 @@ module RubynCode
|
|
|
57
74
|
allowed.empty? ? symbolized : symbolized.slice(*allowed)
|
|
58
75
|
end
|
|
59
76
|
|
|
60
|
-
|
|
77
|
+
# -- tool-specific dependency injection
|
|
78
|
+
def inject_dependencies(tool, tool_name)
|
|
61
79
|
case tool_name
|
|
62
80
|
when 'spawn_agent', 'spawn_teammate'
|
|
63
81
|
inject_agent_deps(tool)
|
|
@@ -88,13 +106,17 @@ module RubynCode
|
|
|
88
106
|
end
|
|
89
107
|
|
|
90
108
|
# Cache read_file results; invalidate on write_file/edit_file.
|
|
109
|
+
# Also detects bash commands that write to files (redirect, sed -i, tee).
|
|
91
110
|
def update_file_cache(tool_name, params, _raw)
|
|
92
111
|
path = resolve_cache_path(params)
|
|
93
|
-
return unless path
|
|
94
112
|
|
|
95
113
|
case tool_name
|
|
96
|
-
when 'read_file'
|
|
97
|
-
|
|
114
|
+
when 'read_file'
|
|
115
|
+
@file_cache.read(path) if path # populates cache
|
|
116
|
+
when 'write_file', 'edit_file'
|
|
117
|
+
@file_cache.on_write(path) if path
|
|
118
|
+
when 'bash'
|
|
119
|
+
invalidate_bash_write_targets(params)
|
|
98
120
|
end
|
|
99
121
|
rescue StandardError
|
|
100
122
|
nil
|
|
@@ -109,6 +131,41 @@ module RubynCode
|
|
|
109
131
|
nil
|
|
110
132
|
end
|
|
111
133
|
|
|
134
|
+
# Trigger an incremental codebase index update after writing a Ruby file.
|
|
135
|
+
# Non-blocking: if the update fails, log and continue.
|
|
136
|
+
def maybe_update_codebase_index(tool_name, params)
|
|
137
|
+
return unless %w[write_file edit_file].include?(tool_name)
|
|
138
|
+
return unless @codebase_index
|
|
139
|
+
|
|
140
|
+
path = resolve_cache_path(params)
|
|
141
|
+
return unless path&.end_with?('.rb')
|
|
142
|
+
|
|
143
|
+
@codebase_index.update!
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
RubynCode::Debug.warn("CodebaseIndex incremental update failed: #{e.message}")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Detect file paths that a bash command may have written to and
|
|
149
|
+
# invalidate them from the file cache.
|
|
150
|
+
def invalidate_bash_write_targets(params)
|
|
151
|
+
command = params[:command] || params['command']
|
|
152
|
+
return unless command.is_a?(String)
|
|
153
|
+
|
|
154
|
+
paths = extract_bash_write_paths(command)
|
|
155
|
+
paths.each do |p|
|
|
156
|
+
resolved = File.expand_path(p, @project_root)
|
|
157
|
+
@file_cache.on_write(resolved)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def extract_bash_write_paths(command)
|
|
162
|
+
paths = []
|
|
163
|
+
BASH_WRITE_PATTERNS.each do |pattern|
|
|
164
|
+
command.scan(pattern) { |match| paths << match[0] if match[0] }
|
|
165
|
+
end
|
|
166
|
+
paths.uniq
|
|
167
|
+
end
|
|
168
|
+
|
|
112
169
|
def error_result(message)
|
|
113
170
|
message
|
|
114
171
|
end
|
|
@@ -21,6 +21,12 @@ module RubynCode
|
|
|
21
21
|
RISK_LEVEL = :read
|
|
22
22
|
REQUIRES_CONFIRMATION = false
|
|
23
23
|
|
|
24
|
+
def self.summarize(output, args)
|
|
25
|
+
pattern = args['pattern'] || args[:pattern] || ''
|
|
26
|
+
count = output.to_s.strip.empty? ? 0 : output.to_s.lines.count
|
|
27
|
+
"glob #{pattern} (#{count} files)"
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
def execute(pattern:, path: nil)
|
|
25
31
|
search_dir = resolve_search_dir(path)
|
|
26
32
|
full_pattern = File.join(search_dir, pattern)
|
|
@@ -20,6 +20,13 @@ module RubynCode
|
|
|
20
20
|
RISK_LEVEL = :read
|
|
21
21
|
REQUIRES_CONFIRMATION = false
|
|
22
22
|
|
|
23
|
+
def self.summarize(output, args)
|
|
24
|
+
pattern = args['pattern'] || args[:pattern] || ''
|
|
25
|
+
count = output.to_s.lines.count
|
|
26
|
+
no_matches = count.zero? || output.to_s.start_with?('No matches')
|
|
27
|
+
no_matches ? "grep #{pattern} (0 matches)" : "grep #{pattern} (#{count} lines)"
|
|
28
|
+
end
|
|
29
|
+
|
|
23
30
|
def execute(pattern:, path: nil, glob_filter: nil, max_results: 50)
|
|
24
31
|
search_path = path ? safe_path(path) : project_root
|
|
25
32
|
regex = Regexp.new(pattern)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tools
|
|
5
|
+
# Retrieves VS Code diagnostics (errors/warnings from the Problems panel)
|
|
6
|
+
# via the IDE RPC bridge. Only available when running in IDE mode.
|
|
7
|
+
class IdeDiagnostics < Base
|
|
8
|
+
TOOL_NAME = 'ide_diagnostics'
|
|
9
|
+
DESCRIPTION =
|
|
10
|
+
'Get VS Code diagnostics (errors, warnings) for a file or the whole workspace. ' \
|
|
11
|
+
'Only available in IDE mode.'
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
file: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'File path to get diagnostics for. Omit to get all workspace diagnostics.'
|
|
16
|
+
}
|
|
17
|
+
}.freeze
|
|
18
|
+
RISK_LEVEL = :read
|
|
19
|
+
|
|
20
|
+
def initialize(project_root:, ide_client: nil)
|
|
21
|
+
super(project_root: project_root)
|
|
22
|
+
@ide_client = ide_client
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def execute(**params)
|
|
26
|
+
unless @ide_client
|
|
27
|
+
return 'IDE diagnostics are only available when running inside VS Code.'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
rpc_params = {}
|
|
31
|
+
rpc_params[:file] = params[:file] if params[:file]
|
|
32
|
+
|
|
33
|
+
result = @ide_client.request('ide/getDiagnostics', rpc_params, timeout: 10)
|
|
34
|
+
diagnostics = result['diagnostics'] || []
|
|
35
|
+
|
|
36
|
+
return 'No diagnostics found.' if diagnostics.empty?
|
|
37
|
+
|
|
38
|
+
lines = diagnostics.map do |d|
|
|
39
|
+
severity = d['severity']&.upcase || 'INFO'
|
|
40
|
+
source = d['source'] ? " (#{d['source']})" : ''
|
|
41
|
+
"#{severity}: #{d['file']}:#{d['line']} — #{d['message']}#{source}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
lines.join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.summarize(output, _args)
|
|
48
|
+
count = output.lines.count
|
|
49
|
+
"#{count} diagnostic(s)"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tools
|
|
5
|
+
# Searches VS Code workspace symbols via the language server.
|
|
6
|
+
# Only available when running in IDE mode.
|
|
7
|
+
class IdeSymbols < Base
|
|
8
|
+
TOOL_NAME = 'ide_symbols'
|
|
9
|
+
DESCRIPTION =
|
|
10
|
+
'Search workspace symbols (classes, methods, modules) via VS Code language server. ' \
|
|
11
|
+
'Only available in IDE mode.'
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
query: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Symbol search query (e.g. "User", "authenticate")',
|
|
16
|
+
required: true
|
|
17
|
+
}
|
|
18
|
+
}.freeze
|
|
19
|
+
RISK_LEVEL = :read
|
|
20
|
+
|
|
21
|
+
def initialize(project_root:, ide_client: nil)
|
|
22
|
+
super(project_root: project_root)
|
|
23
|
+
@ide_client = ide_client
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def execute(**params)
|
|
27
|
+
unless @ide_client
|
|
28
|
+
return 'IDE symbols are only available when running inside VS Code.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
query = params[:query] || ''
|
|
32
|
+
return 'Query is required.' if query.empty?
|
|
33
|
+
|
|
34
|
+
result = @ide_client.request('ide/getWorkspaceSymbols', { query: query }, timeout: 10)
|
|
35
|
+
symbols = result['symbols'] || []
|
|
36
|
+
|
|
37
|
+
return "No symbols found matching '#{query}'." if symbols.empty?
|
|
38
|
+
|
|
39
|
+
lines = symbols.first(50).map do |s|
|
|
40
|
+
container = s['containerName'] ? " (in #{s['containerName']})" : ''
|
|
41
|
+
line_info = s['line'] ? ":#{s['line']}" : ''
|
|
42
|
+
"#{s['kind']} #{s['name']}#{container} — #{s['file']}#{line_info}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
header = "Found #{symbols.size} symbol(s) matching '#{query}':"
|
|
46
|
+
([header] + lines).join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.summarize(output, _args)
|
|
50
|
+
first_line = output.lines.first&.strip || ''
|
|
51
|
+
first_line.start_with?('Found') ? first_line : ''
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -36,7 +36,8 @@ module RubynCode
|
|
|
36
36
|
skills_dirs = [
|
|
37
37
|
File.expand_path('../../../skills', __dir__), # bundled gem skills
|
|
38
38
|
File.join(project_root, '.rubyn-code', 'skills'), # project skills
|
|
39
|
-
File.join(Dir.home, '.rubyn-code', 'skills') # global user skills
|
|
39
|
+
File.join(Dir.home, '.rubyn-code', 'skills'), # global user skills
|
|
40
|
+
File.join(Dir.home, '.rubyn-code', 'skill-packs') # registry-installed packs
|
|
40
41
|
]
|
|
41
42
|
catalog = Skills::Catalog.new(skills_dirs.select { |d| Dir.exist?(d) })
|
|
42
43
|
Skills::Loader.new(catalog)
|
|
@@ -63,7 +63,12 @@ module RubynCode
|
|
|
63
63
|
failures = extract_spec_failures(lines)
|
|
64
64
|
return summary_line.strip if failures.empty? && summary_line
|
|
65
65
|
|
|
66
|
-
assemble_failure_report(failures, summary_line)
|
|
66
|
+
result = assemble_failure_report(failures, summary_line)
|
|
67
|
+
|
|
68
|
+
# Guard: if compression produced an empty string (no summary line
|
|
69
|
+
# and no extractable failures), return the original output so the
|
|
70
|
+
# agent still sees spec results.
|
|
71
|
+
result.nil? || result.strip.empty? ? output : result
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
def find_summary_line(lines)
|
|
@@ -98,7 +103,7 @@ module RubynCode
|
|
|
98
103
|
failures
|
|
99
104
|
end
|
|
100
105
|
|
|
101
|
-
#
|
|
106
|
+
# -- head/tail splitting requires coordinated arithmetic
|
|
102
107
|
def head_tail(output, max_chars)
|
|
103
108
|
lines = output.lines
|
|
104
109
|
return output if lines.size <= 10
|
|
@@ -115,9 +120,8 @@ module RubynCode
|
|
|
115
120
|
parts << tail_lines.join
|
|
116
121
|
parts.join
|
|
117
122
|
end
|
|
118
|
-
# rubocop:enable Metrics/AbcSize
|
|
119
123
|
|
|
120
|
-
#
|
|
124
|
+
# -- diff hunk iteration with header extraction
|
|
121
125
|
def compress_diff(output, max_chars)
|
|
122
126
|
hunks = output.split(/^(?=diff --git)/)
|
|
123
127
|
return head_tail(output, max_chars) if hunks.size <= 1
|
|
@@ -135,7 +139,6 @@ module RubynCode
|
|
|
135
139
|
|
|
136
140
|
result
|
|
137
141
|
end
|
|
138
|
-
# rubocop:enable Metrics/AbcSize
|
|
139
142
|
|
|
140
143
|
def top_matches(output, max_chars)
|
|
141
144
|
lines = output.lines
|
|
@@ -147,7 +150,7 @@ module RubynCode
|
|
|
147
150
|
result
|
|
148
151
|
end
|
|
149
152
|
|
|
150
|
-
#
|
|
153
|
+
# -- multi-step tree collapse
|
|
151
154
|
def collapse_tree(output, max_chars)
|
|
152
155
|
paths = output.lines.map(&:strip).reject(&:empty?)
|
|
153
156
|
return output if output.length <= max_chars
|
|
@@ -159,7 +162,6 @@ module RubynCode
|
|
|
159
162
|
|
|
160
163
|
head_tail(result, max_chars)
|
|
161
164
|
end
|
|
162
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
163
165
|
|
|
164
166
|
def take_lines_up_to(lines, max_chars)
|
|
165
167
|
taken = []
|
|
@@ -17,6 +17,12 @@ module RubynCode
|
|
|
17
17
|
RISK_LEVEL = :read
|
|
18
18
|
REQUIRES_CONFIRMATION = false
|
|
19
19
|
|
|
20
|
+
def self.summarize(output, args)
|
|
21
|
+
path = args['path'] || args[:path] || ''
|
|
22
|
+
line_count = output.to_s.lines.count
|
|
23
|
+
"Read #{path} (#{line_count} lines)"
|
|
24
|
+
end
|
|
25
|
+
|
|
20
26
|
def execute(path:, offset: nil, limit: nil)
|
|
21
27
|
resolved = read_file_safely(path)
|
|
22
28
|
lines = File.readlines(resolved)
|
|
@@ -33,15 +33,26 @@ module RubynCode
|
|
|
33
33
|
@tools = {}
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# IDE-only tools that require an ide_client to function.
|
|
37
|
+
IDE_ONLY_TOOLS = %w[ide_diagnostics ide_symbols].freeze
|
|
38
|
+
|
|
36
39
|
def load_all!
|
|
37
40
|
tool_files = Dir[File.join(__dir__, '*.rb')]
|
|
38
41
|
tool_files.each do |file|
|
|
39
42
|
basename = File.basename(file, '.rb')
|
|
40
43
|
next if %w[base registry schema executor].include?(basename)
|
|
44
|
+
next if IDE_ONLY_TOOLS.include?(basename)
|
|
41
45
|
|
|
42
46
|
require_relative basename
|
|
43
47
|
end
|
|
44
48
|
end
|
|
49
|
+
|
|
50
|
+
# Register IDE-only tools when an ide_client is available.
|
|
51
|
+
def load_ide_tools!
|
|
52
|
+
IDE_ONLY_TOOLS.each do |name|
|
|
53
|
+
require_relative name
|
|
54
|
+
end
|
|
55
|
+
end
|
|
45
56
|
end
|
|
46
57
|
end
|
|
47
58
|
end
|
|
@@ -24,7 +24,7 @@ module RubynCode
|
|
|
24
24
|
}.freeze
|
|
25
25
|
RISK_LEVEL = :read
|
|
26
26
|
|
|
27
|
-
def execute(base_branch: 'main', focus: 'all')
|
|
27
|
+
def execute(base_branch: 'main', focus: 'all', pack_context: nil)
|
|
28
28
|
error = validate_git_repo
|
|
29
29
|
return error if error
|
|
30
30
|
|
|
@@ -37,7 +37,7 @@ module RubynCode
|
|
|
37
37
|
diff = run_git("diff #{base_branch}...HEAD")
|
|
38
38
|
return "No changes found between #{current} and #{base_branch}." if diff.strip.empty?
|
|
39
39
|
|
|
40
|
-
build_full_review(current, base_branch, diff, focus)
|
|
40
|
+
build_full_review(current, base_branch, diff, focus, pack_context: pack_context)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
FILE_CATEGORIES = [
|
|
@@ -80,8 +80,9 @@ module RubynCode
|
|
|
80
80
|
[nil, "Error: Base branch '#{base_branch}' not found."]
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
def build_full_review(current, base_branch, diff, focus)
|
|
84
|
-
review =
|
|
83
|
+
def build_full_review(current, base_branch, diff, focus, pack_context: nil)
|
|
84
|
+
review = build_pack_context_section(pack_context)
|
|
85
|
+
review.concat(build_review_header(current, base_branch))
|
|
85
86
|
review.concat(build_file_categories(base_branch))
|
|
86
87
|
review.concat(build_focus_section(focus))
|
|
87
88
|
review.concat(build_diff_section(diff))
|
|
@@ -158,6 +159,16 @@ module RubynCode
|
|
|
158
159
|
]
|
|
159
160
|
end
|
|
160
161
|
|
|
162
|
+
def build_pack_context_section(pack_context)
|
|
163
|
+
return [] if pack_context.nil? || pack_context.strip.empty?
|
|
164
|
+
|
|
165
|
+
[
|
|
166
|
+
'## Skill Pack Context',
|
|
167
|
+
pack_context.strip,
|
|
168
|
+
''
|
|
169
|
+
]
|
|
170
|
+
end
|
|
171
|
+
|
|
161
172
|
def run_git(command)
|
|
162
173
|
`cd #{project_root} && git #{command} 2>/dev/null`
|
|
163
174
|
end
|
|
@@ -85,7 +85,8 @@ module RubynCode
|
|
|
85
85
|
html.scan(%r{<a[^>]+href="(https?://(?!lite\.duckduckgo)[^"]+)"[^>]*>(.*?)</a>}i)
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
# -- HTML parsing with filtering
|
|
89
|
+
def build_ddg_results(links, snippets, max)
|
|
89
90
|
results = []
|
|
90
91
|
links.each_with_index do |match, idx|
|
|
91
92
|
break if results.length >= max
|
|
@@ -18,6 +18,12 @@ module RubynCode
|
|
|
18
18
|
|
|
19
19
|
PREVIEW_LINES = 15
|
|
20
20
|
|
|
21
|
+
# Take the first line of the tool's output, which is already formatted
|
|
22
|
+
# as "Updated /path.rb (N bytes)" or "Created /path.rb (N bytes)".
|
|
23
|
+
def self.summarize(output, _args)
|
|
24
|
+
output.to_s.lines.first.to_s.chomp[0, 200]
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
def execute(path:, content:)
|
|
22
28
|
resolved = safe_path(path)
|
|
23
29
|
existed = File.exist?(resolved)
|
|
@@ -29,6 +35,17 @@ module RubynCode
|
|
|
29
35
|
format_result(path, bytes, existed, old_content, content)
|
|
30
36
|
end
|
|
31
37
|
|
|
38
|
+
# Compute the proposed file content without writing to disk.
|
|
39
|
+
# Used by IDE mode to preview the write in a diff view (modify) or
|
|
40
|
+
# preview tab (create) before the user accepts.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash] { content: String, type: 'modify' | 'create' }
|
|
43
|
+
def preview_content(path:, content:)
|
|
44
|
+
resolved = safe_path(path)
|
|
45
|
+
type = File.exist?(resolved) ? 'modify' : 'create'
|
|
46
|
+
{ content: content, type: type }
|
|
47
|
+
end
|
|
48
|
+
|
|
32
49
|
private
|
|
33
50
|
|
|
34
51
|
def format_result(path, bytes, existed, old_content, new_content)
|
data/lib/rubyn_code/version.rb
CHANGED
data/lib/rubyn_code.rb
CHANGED
|
@@ -11,6 +11,10 @@ module RubynCode
|
|
|
11
11
|
class StallDetectedError < Error; end
|
|
12
12
|
class ToolNotFoundError < Error; end
|
|
13
13
|
class ConfigError < Error; end
|
|
14
|
+
# Raised when the user refuses a tool invocation in IDE mode. Signals the
|
|
15
|
+
# agent loop to surface this as is_error: true so the model sees a refusal
|
|
16
|
+
# rather than a successful tool call returning a string like "denied".
|
|
17
|
+
class UserDeniedError < Error; end
|
|
14
18
|
|
|
15
19
|
# Infrastructure
|
|
16
20
|
autoload :Config, 'rubyn_code/config/settings'
|
|
@@ -29,6 +33,7 @@ module RubynCode
|
|
|
29
33
|
# Auth
|
|
30
34
|
module Auth
|
|
31
35
|
autoload :OAuth, 'rubyn_code/auth/oauth'
|
|
36
|
+
autoload :KeyEncryption, 'rubyn_code/auth/key_encryption'
|
|
32
37
|
autoload :TokenStore, 'rubyn_code/auth/token_store'
|
|
33
38
|
autoload :Server, 'rubyn_code/auth/server'
|
|
34
39
|
end
|
|
@@ -45,6 +50,7 @@ module RubynCode
|
|
|
45
50
|
autoload :JsonParsing, 'rubyn_code/llm/adapters/json_parsing'
|
|
46
51
|
autoload :PromptCaching, 'rubyn_code/llm/adapters/prompt_caching'
|
|
47
52
|
autoload :Anthropic, 'rubyn_code/llm/adapters/anthropic'
|
|
53
|
+
autoload :AnthropicCompatible, 'rubyn_code/llm/adapters/anthropic_compatible'
|
|
48
54
|
autoload :AnthropicStreaming, 'rubyn_code/llm/adapters/anthropic_streaming'
|
|
49
55
|
autoload :OpenAI, 'rubyn_code/llm/adapters/openai'
|
|
50
56
|
autoload :OpenAIStreaming, 'rubyn_code/llm/adapters/openai_streaming'
|
|
@@ -131,7 +137,16 @@ module RubynCode
|
|
|
131
137
|
autoload :Loader, 'rubyn_code/skills/loader'
|
|
132
138
|
autoload :Catalog, 'rubyn_code/skills/catalog'
|
|
133
139
|
autoload :Document, 'rubyn_code/skills/document'
|
|
140
|
+
autoload :Matcher, 'rubyn_code/skills/matcher'
|
|
141
|
+
autoload :RegistryAutoload, 'rubyn_code/skills/registry_autoload'
|
|
134
142
|
autoload :TtlManager, 'rubyn_code/skills/ttl_manager'
|
|
143
|
+
autoload :RegistryClient, 'rubyn_code/skills/registry_client'
|
|
144
|
+
autoload :PackManager, 'rubyn_code/skills/pack_manager'
|
|
145
|
+
autoload :PackInstaller, 'rubyn_code/skills/pack_installer'
|
|
146
|
+
autoload :PackContext, 'rubyn_code/skills/pack_context'
|
|
147
|
+
autoload :GemfileParser, 'rubyn_code/skills/gemfile_parser'
|
|
148
|
+
autoload :AutoSuggest, 'rubyn_code/skills/auto_suggest'
|
|
149
|
+
autoload :RegistryError, 'rubyn_code/skills/registry_client'
|
|
135
150
|
end
|
|
136
151
|
|
|
137
152
|
# Layer 6: Sub-Agents
|
|
@@ -220,6 +235,19 @@ module RubynCode
|
|
|
220
235
|
autoload :Shortcut, 'rubyn_code/learning/shortcut'
|
|
221
236
|
end
|
|
222
237
|
|
|
238
|
+
# IDE (VS Code extension server)
|
|
239
|
+
module IDE
|
|
240
|
+
autoload :Protocol, 'rubyn_code/ide/protocol'
|
|
241
|
+
autoload :Server, 'rubyn_code/ide/server'
|
|
242
|
+
|
|
243
|
+
module Adapters
|
|
244
|
+
autoload :ToolOutput, 'rubyn_code/ide/adapters/tool_output'
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Self-Test
|
|
249
|
+
autoload :SelfTest, 'rubyn_code/self_test'
|
|
250
|
+
|
|
223
251
|
# Codebase Index
|
|
224
252
|
module Index
|
|
225
253
|
autoload :CodebaseIndex, 'rubyn_code/index/codebase_index'
|
|
@@ -234,6 +262,7 @@ module RubynCode
|
|
|
234
262
|
autoload :Spinner, 'rubyn_code/cli/spinner'
|
|
235
263
|
autoload :StreamFormatter, 'rubyn_code/cli/stream_formatter'
|
|
236
264
|
autoload :Setup, 'rubyn_code/cli/setup'
|
|
265
|
+
autoload :FirstRun, 'rubyn_code/cli/first_run'
|
|
237
266
|
autoload :DaemonRunner, 'rubyn_code/cli/daemon_runner'
|
|
238
267
|
autoload :VersionCheck, 'rubyn_code/cli/version_check'
|
|
239
268
|
|
|
@@ -260,8 +289,13 @@ module RubynCode
|
|
|
260
289
|
autoload :Plan, 'rubyn_code/cli/commands/plan'
|
|
261
290
|
autoload :ContextInfo, 'rubyn_code/cli/commands/context_info'
|
|
262
291
|
autoload :Diff, 'rubyn_code/cli/commands/diff'
|
|
292
|
+
autoload :Mcp, 'rubyn_code/cli/commands/mcp'
|
|
263
293
|
autoload :Model, 'rubyn_code/cli/commands/model'
|
|
264
294
|
autoload :NewSession, 'rubyn_code/cli/commands/new_session'
|
|
295
|
+
autoload :Provider, 'rubyn_code/cli/commands/provider'
|
|
296
|
+
autoload :InstallSkills, 'rubyn_code/cli/commands/install_skills'
|
|
297
|
+
autoload :RemoveSkills, 'rubyn_code/cli/commands/remove_skills'
|
|
298
|
+
autoload :Skills, 'rubyn_code/cli/commands/skills'
|
|
265
299
|
end
|
|
266
300
|
end
|
|
267
301
|
|