rubyn-code 0.2.2 → 0.4.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 +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -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 +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -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 +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -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 +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- 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/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 +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -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 +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +311 -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 +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -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 +50 -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 +75 -247
- 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 +10 -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/search.rb +1 -0
- 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/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- 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/tasks/models.rb +1 -0
- 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 +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- 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 +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- 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 +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- 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 +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- metadata +83 -1
|
@@ -3,30 +3,30 @@
|
|
|
3
3
|
module RubynCode
|
|
4
4
|
module Tools
|
|
5
5
|
class Executor
|
|
6
|
-
attr_reader :project_root
|
|
7
|
-
attr_accessor :llm_client, :background_worker, :on_agent_status, :db
|
|
6
|
+
attr_reader :project_root, :output_compressor, :file_cache
|
|
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 = {}
|
|
14
|
+
@output_compressor = OutputCompressor.new
|
|
15
|
+
@file_cache = FileCache.new
|
|
12
16
|
Registry.load_all!
|
|
13
17
|
end
|
|
14
18
|
|
|
15
|
-
def execute(tool_name, params)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
# Inject dependencies for tools that need them
|
|
20
|
-
inject_dependencies(tool, tool_name)
|
|
19
|
+
def execute(tool_name, params) # rubocop:disable Metrics/AbcSize -- maps tool errors to results
|
|
20
|
+
# File cache intercept: serve cached reads, invalidate on writes
|
|
21
|
+
cached = try_file_cache(tool_name, params)
|
|
22
|
+
return cached if cached
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
result = tool.execute(**filtered)
|
|
29
|
-
tool.truncate(result.to_s)
|
|
24
|
+
tool = build_tool(tool_name)
|
|
25
|
+
filtered = filter_params(tool, params)
|
|
26
|
+
raw = tool.truncate(tool.execute(**filtered).to_s)
|
|
27
|
+
update_file_cache(tool_name, filtered, raw)
|
|
28
|
+
maybe_update_codebase_index(tool_name, filtered)
|
|
29
|
+
@output_compressor.compress(tool_name, raw)
|
|
30
30
|
rescue ToolNotFoundError => e
|
|
31
31
|
error_result("Tool error: #{e.message}")
|
|
32
32
|
rescue PermissionDeniedError => e
|
|
@@ -43,20 +43,125 @@ module RubynCode
|
|
|
43
43
|
Registry.tool_definitions
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Patterns that indicate a bash command writes to a file.
|
|
47
|
+
BASH_WRITE_PATTERNS = [
|
|
48
|
+
/(?:>>?)\s*(\S+)/, # > file or >> file
|
|
49
|
+
/\btee\s+(?:-a\s+)?(\S+)/, # tee file or tee -a file
|
|
50
|
+
/\bsed\s+-i\S*\s+.*\s(\S+)$/, # sed -i 's/...' file
|
|
51
|
+
/\bsed\s+-i\S*\s+.*\s(\S+)\s/ # sed -i 's/...' file (mid-command)
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
46
54
|
private
|
|
47
55
|
|
|
48
|
-
def
|
|
56
|
+
def build_tool(tool_name)
|
|
57
|
+
tool_class = Registry.get(tool_name)
|
|
58
|
+
# IDE-aware tools accept an ide_client parameter.
|
|
59
|
+
if @ide_client && tool_class.method(:new).parameters.any? { |_, name| name == :ide_client }
|
|
60
|
+
tool = tool_class.new(project_root: project_root, ide_client: @ide_client)
|
|
61
|
+
else
|
|
62
|
+
tool = tool_class.new(project_root: project_root)
|
|
63
|
+
end
|
|
64
|
+
inject_dependencies(tool, tool_name)
|
|
65
|
+
tool
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def filter_params(tool, params)
|
|
69
|
+
symbolized = params.transform_keys(&:to_sym)
|
|
70
|
+
allowed = tool.method(:execute).parameters
|
|
71
|
+
.select { |type, _| %i[key keyreq].include?(type) } # rubocop:disable Style/HashSlice
|
|
72
|
+
.map(&:last)
|
|
73
|
+
allowed.empty? ? symbolized : symbolized.slice(*allowed)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def inject_dependencies(tool, tool_name) # rubocop:disable Metrics/CyclomaticComplexity -- tool-specific dependency injection
|
|
49
77
|
case tool_name
|
|
50
|
-
when 'spawn_agent'
|
|
51
|
-
tool
|
|
52
|
-
tool.
|
|
53
|
-
when 'spawn_teammate'
|
|
54
|
-
tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
|
|
55
|
-
tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
|
|
56
|
-
tool.db = @db if tool.respond_to?(:db=)
|
|
78
|
+
when 'spawn_agent', 'spawn_teammate'
|
|
79
|
+
inject_agent_deps(tool)
|
|
80
|
+
tool.db = @db if tool_name == 'spawn_teammate' && tool.respond_to?(:db=)
|
|
57
81
|
when 'background_run'
|
|
58
82
|
tool.background_worker = @background_worker if tool.respond_to?(:background_worker=)
|
|
83
|
+
when 'ask_user'
|
|
84
|
+
tool.prompt_callback = @ask_user_callback if tool.respond_to?(:prompt_callback=)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inject_agent_deps(tool)
|
|
89
|
+
tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
|
|
90
|
+
tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Serve read_file from cache if the file hasn't changed.
|
|
94
|
+
def try_file_cache(tool_name, params)
|
|
95
|
+
return nil unless tool_name == 'read_file'
|
|
96
|
+
|
|
97
|
+
path = resolve_cache_path(params)
|
|
98
|
+
return nil unless path && @file_cache.cached?(path)
|
|
99
|
+
|
|
100
|
+
result = @file_cache.read(path)
|
|
101
|
+
result[:content]
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Cache read_file results; invalidate on write_file/edit_file.
|
|
107
|
+
# Also detects bash commands that write to files (redirect, sed -i, tee).
|
|
108
|
+
def update_file_cache(tool_name, params, _raw)
|
|
109
|
+
path = resolve_cache_path(params)
|
|
110
|
+
|
|
111
|
+
case tool_name
|
|
112
|
+
when 'read_file'
|
|
113
|
+
@file_cache.read(path) if path # populates cache
|
|
114
|
+
when 'write_file', 'edit_file'
|
|
115
|
+
@file_cache.on_write(path) if path
|
|
116
|
+
when 'bash'
|
|
117
|
+
invalidate_bash_write_targets(params)
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resolve_cache_path(params)
|
|
124
|
+
p = params[:path] || params['path']
|
|
125
|
+
return nil unless p
|
|
126
|
+
|
|
127
|
+
File.expand_path(p, @project_root)
|
|
128
|
+
rescue StandardError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Trigger an incremental codebase index update after writing a Ruby file.
|
|
133
|
+
# Non-blocking: if the update fails, log and continue.
|
|
134
|
+
def maybe_update_codebase_index(tool_name, params)
|
|
135
|
+
return unless %w[write_file edit_file].include?(tool_name)
|
|
136
|
+
return unless @codebase_index
|
|
137
|
+
|
|
138
|
+
path = resolve_cache_path(params)
|
|
139
|
+
return unless path&.end_with?('.rb')
|
|
140
|
+
|
|
141
|
+
@codebase_index.update!
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
RubynCode::Debug.warn("CodebaseIndex incremental update failed: #{e.message}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Detect file paths that a bash command may have written to and
|
|
147
|
+
# invalidate them from the file cache.
|
|
148
|
+
def invalidate_bash_write_targets(params)
|
|
149
|
+
command = params[:command] || params['command']
|
|
150
|
+
return unless command.is_a?(String)
|
|
151
|
+
|
|
152
|
+
paths = extract_bash_write_paths(command)
|
|
153
|
+
paths.each do |p|
|
|
154
|
+
resolved = File.expand_path(p, @project_root)
|
|
155
|
+
@file_cache.on_write(resolved)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def extract_bash_write_paths(command)
|
|
160
|
+
paths = []
|
|
161
|
+
BASH_WRITE_PATTERNS.each do |pattern|
|
|
162
|
+
command.scan(pattern) { |match| paths << match[0] if match[0] }
|
|
59
163
|
end
|
|
164
|
+
paths.uniq
|
|
60
165
|
end
|
|
61
166
|
|
|
62
167
|
def error_result(message)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tools
|
|
5
|
+
# Session-scoped file cache that serves previously-read file contents
|
|
6
|
+
# when the file has not been modified since the last read. Invalidates
|
|
7
|
+
# automatically when Rubyn writes or edits a file, or when mtime changes.
|
|
8
|
+
class FileCache
|
|
9
|
+
CHARS_PER_TOKEN = 4
|
|
10
|
+
|
|
11
|
+
Entry = Data.define(:content, :mtime, :token_count, :read_count, :cache_hits)
|
|
12
|
+
|
|
13
|
+
attr_reader :cache
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@cache = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns cached content if the file hasn't changed, otherwise reads
|
|
20
|
+
# from disk and caches the result.
|
|
21
|
+
#
|
|
22
|
+
# @param path [String] absolute file path
|
|
23
|
+
# @return [Hash] { content:, source: :cache|:disk, tokens_saved: }
|
|
24
|
+
def read(path)
|
|
25
|
+
current_mtime = File.mtime(path)
|
|
26
|
+
cached = @cache[path]
|
|
27
|
+
|
|
28
|
+
if cached && cached.mtime == current_mtime
|
|
29
|
+
bump_hits(path)
|
|
30
|
+
{ content: cached.content, source: :cache, tokens_saved: cached.token_count }
|
|
31
|
+
else
|
|
32
|
+
content = File.read(path)
|
|
33
|
+
token_count = estimate_tokens(content)
|
|
34
|
+
@cache[path] = Entry.new(
|
|
35
|
+
content: content, mtime: current_mtime,
|
|
36
|
+
token_count: token_count, read_count: 1, cache_hits: 0
|
|
37
|
+
)
|
|
38
|
+
{ content: content, source: :disk, tokens_saved: 0 }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Removes a path from the cache. Called when Rubyn writes/edits the file.
|
|
43
|
+
def invalidate(path)
|
|
44
|
+
@cache.delete(path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Alias for use as a write hook.
|
|
48
|
+
def on_write(path)
|
|
49
|
+
invalidate(path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns true if the given path is currently cached and fresh.
|
|
53
|
+
def cached?(path)
|
|
54
|
+
return false unless @cache.key?(path)
|
|
55
|
+
|
|
56
|
+
@cache[path].mtime == File.mtime(path)
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
@cache.delete(path)
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Clears the entire cache.
|
|
63
|
+
def clear!
|
|
64
|
+
@cache.clear
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns aggregate statistics about cache performance.
|
|
68
|
+
def stats
|
|
69
|
+
total_reads = @cache.values.sum(&:read_count)
|
|
70
|
+
total_hits = @cache.values.sum(&:cache_hits)
|
|
71
|
+
tokens_saved = @cache.values.sum { |e| e.cache_hits * e.token_count }
|
|
72
|
+
hit_rate = total_reads.positive? ? total_hits.to_f / (total_reads + total_hits) : 0.0
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
entries: @cache.size,
|
|
76
|
+
total_reads: total_reads,
|
|
77
|
+
cache_hits: total_hits,
|
|
78
|
+
hit_rate: hit_rate.round(3),
|
|
79
|
+
tokens_saved: tokens_saved
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def bump_hits(path)
|
|
86
|
+
old = @cache[path]
|
|
87
|
+
@cache[path] = old.with(cache_hits: old.cache_hits + 1)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def estimate_tokens(content)
|
|
91
|
+
(content.bytesize.to_f / CHARS_PER_TOKEN).ceil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -58,21 +58,23 @@ module RubynCode
|
|
|
58
58
|
def create_commit(message)
|
|
59
59
|
stdout, stderr, status = safe_capture3('git', 'commit', '-m', message, chdir: project_root)
|
|
60
60
|
|
|
61
|
-
unless status.success?
|
|
62
|
-
return 'Nothing to commit — working tree is clean.' if stderr.include?('nothing to commit')
|
|
61
|
+
return handle_commit_failure(stdout, stderr) unless status.success?
|
|
63
62
|
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
format_commit_output(stdout)
|
|
64
|
+
end
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
def handle_commit_failure(stdout, stderr)
|
|
67
|
+
output = "#{stdout}\n#{stderr}"
|
|
68
|
+
return 'Nothing to commit — working tree is clean.' if output.include?('nothing to commit')
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
raise Error, "Commit failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_commit_output(stdout)
|
|
74
|
+
lines = ["Committed on branch: #{current_branch}"]
|
|
75
|
+
lines << "Commit: #{extract_commit_hash}" if extract_commit_hash
|
|
73
76
|
lines << ''
|
|
74
77
|
lines << stdout.strip
|
|
75
|
-
|
|
76
78
|
lines.join("\n")
|
|
77
79
|
end
|
|
78
80
|
|
|
@@ -18,26 +18,28 @@ module RubynCode
|
|
|
18
18
|
|
|
19
19
|
def execute(count: 20, branch: nil)
|
|
20
20
|
validate_git_repo!
|
|
21
|
+
stdout = run_git_log(count.to_i.clamp(1, 200), branch)
|
|
22
|
+
format_log_output(stdout, branch)
|
|
23
|
+
end
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
private
|
|
23
26
|
|
|
27
|
+
def run_git_log(count, branch)
|
|
24
28
|
cmd = ['git', 'log', '--oneline', "-#{count}"]
|
|
25
29
|
cmd << branch unless branch.nil? || branch.strip.empty?
|
|
26
30
|
|
|
27
31
|
stdout, stderr, status = safe_capture3(*cmd, chdir: project_root)
|
|
28
|
-
|
|
29
32
|
raise Error, "git log failed: #{stderr.strip}" unless status.success?
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
'No commits found.'
|
|
33
|
-
else
|
|
34
|
-
current = current_branch
|
|
35
|
-
header = "Commit history#{branch ? " (#{branch})" : " (#{current})"}:\n\n"
|
|
36
|
-
truncate("#{header}#{stdout}", max: 50_000)
|
|
37
|
-
end
|
|
34
|
+
stdout
|
|
38
35
|
end
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
def format_log_output(stdout, branch)
|
|
38
|
+
return 'No commits found.' if stdout.strip.empty?
|
|
39
|
+
|
|
40
|
+
display_branch = branch || current_branch
|
|
41
|
+
truncate("Commit history (#{display_branch}):\n\n#{stdout}", max: 50_000)
|
|
42
|
+
end
|
|
41
43
|
|
|
42
44
|
def validate_git_repo!
|
|
43
45
|
_, _, status = safe_capture3('git', 'rev-parse', '--is-inside-work-tree', chdir: project_root)
|
|
@@ -9,29 +9,51 @@ module RubynCode
|
|
|
9
9
|
TOOL_NAME = 'glob'
|
|
10
10
|
DESCRIPTION = 'File pattern matching. Returns sorted list of file paths matching the glob pattern.'
|
|
11
11
|
PARAMETERS = {
|
|
12
|
-
pattern: {
|
|
13
|
-
|
|
12
|
+
pattern: {
|
|
13
|
+
type: :string, required: true,
|
|
14
|
+
description: "Glob pattern (e.g. '**/*.rb', 'app/**/*.erb')"
|
|
15
|
+
},
|
|
16
|
+
path: {
|
|
17
|
+
type: :string, required: false,
|
|
18
|
+
description: 'Directory to search in (defaults to project root)'
|
|
19
|
+
}
|
|
14
20
|
}.freeze
|
|
15
21
|
RISK_LEVEL = :read
|
|
16
22
|
REQUIRES_CONFIRMATION = false
|
|
17
23
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
22
29
|
|
|
30
|
+
def execute(pattern:, path: nil)
|
|
31
|
+
search_dir = resolve_search_dir(path)
|
|
23
32
|
full_pattern = File.join(search_dir, pattern)
|
|
24
33
|
matches = Dir.glob(full_pattern, File::FNM_DOTMATCH).sort
|
|
25
34
|
|
|
26
35
|
matches
|
|
27
36
|
.select { |f| File.file?(f) }
|
|
28
|
-
.reject { |f|
|
|
37
|
+
.reject { |f| dot_entry?(f) }
|
|
29
38
|
.map { |f| relative_to_root(f) }
|
|
30
39
|
.join("\n")
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
private
|
|
34
43
|
|
|
44
|
+
def resolve_search_dir(path)
|
|
45
|
+
search_dir = path ? safe_path(path) : project_root
|
|
46
|
+
|
|
47
|
+
raise Error, "Directory not found: #{path || project_root}" unless File.directory?(search_dir)
|
|
48
|
+
|
|
49
|
+
search_dir
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dot_entry?(file)
|
|
53
|
+
basename = File.basename(file)
|
|
54
|
+
['.', '..'].include?(basename)
|
|
55
|
+
end
|
|
56
|
+
|
|
35
57
|
def relative_to_root(absolute_path)
|
|
36
58
|
absolute_path.delete_prefix("#{project_root}/")
|
|
37
59
|
end
|
|
@@ -7,7 +7,8 @@ module RubynCode
|
|
|
7
7
|
module Tools
|
|
8
8
|
class Grep < Base
|
|
9
9
|
TOOL_NAME = 'grep'
|
|
10
|
-
DESCRIPTION = 'Searches file contents using regular expressions.
|
|
10
|
+
DESCRIPTION = 'Searches file contents using regular expressions. ' \
|
|
11
|
+
'Returns matching lines with file paths and line numbers.'
|
|
11
12
|
PARAMETERS = {
|
|
12
13
|
pattern: { type: :string, required: true, description: 'Regular expression pattern to search for' },
|
|
13
14
|
path: { type: :string, required: false,
|
|
@@ -19,6 +20,12 @@ module RubynCode
|
|
|
19
20
|
RISK_LEVEL = :read
|
|
20
21
|
REQUIRES_CONFIRMATION = false
|
|
21
22
|
|
|
23
|
+
def self.summarize(output, args)
|
|
24
|
+
pattern = args['pattern'] || args[:pattern] || ''
|
|
25
|
+
count = output.to_s.lines.count
|
|
26
|
+
count.zero? || output.to_s.start_with?('No matches') ? "grep #{pattern} (0 matches)" : "grep #{pattern} (#{count} lines)"
|
|
27
|
+
end
|
|
28
|
+
|
|
22
29
|
def execute(pattern:, path: nil, glob_filter: nil, max_results: 50)
|
|
23
30
|
search_path = path ? safe_path(path) : project_root
|
|
24
31
|
regex = Regexp.new(pattern)
|
|
@@ -0,0 +1,51 @@
|
|
|
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 = 'Get VS Code diagnostics (errors, warnings) for a file or the whole workspace. Only available in IDE mode.'
|
|
10
|
+
PARAMETERS = {
|
|
11
|
+
file: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: 'File path to get diagnostics for. Omit to get all workspace diagnostics.'
|
|
14
|
+
}
|
|
15
|
+
}.freeze
|
|
16
|
+
RISK_LEVEL = :read
|
|
17
|
+
|
|
18
|
+
def initialize(project_root:, ide_client: nil)
|
|
19
|
+
super(project_root: project_root)
|
|
20
|
+
@ide_client = ide_client
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute(**params)
|
|
24
|
+
unless @ide_client
|
|
25
|
+
return 'IDE diagnostics are only available when running inside VS Code.'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
rpc_params = {}
|
|
29
|
+
rpc_params[:file] = params[:file] if params[:file]
|
|
30
|
+
|
|
31
|
+
result = @ide_client.request('ide/getDiagnostics', rpc_params, timeout: 10)
|
|
32
|
+
diagnostics = result['diagnostics'] || []
|
|
33
|
+
|
|
34
|
+
return 'No diagnostics found.' if diagnostics.empty?
|
|
35
|
+
|
|
36
|
+
lines = diagnostics.map do |d|
|
|
37
|
+
severity = d['severity']&.upcase || 'INFO'
|
|
38
|
+
source = d['source'] ? " (#{d['source']})" : ''
|
|
39
|
+
"#{severity}: #{d['file']}:#{d['line']} — #{d['message']}#{source}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
lines.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.summarize(output, _args)
|
|
46
|
+
count = output.lines.count
|
|
47
|
+
"#{count} diagnostic(s)"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
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 = 'Search workspace symbols (classes, methods, modules) via VS Code language server. Only available in IDE mode.'
|
|
10
|
+
PARAMETERS = {
|
|
11
|
+
query: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: 'Symbol search query (e.g. "User", "authenticate")',
|
|
14
|
+
required: true
|
|
15
|
+
}
|
|
16
|
+
}.freeze
|
|
17
|
+
RISK_LEVEL = :read
|
|
18
|
+
|
|
19
|
+
def initialize(project_root:, ide_client: nil)
|
|
20
|
+
super(project_root: project_root)
|
|
21
|
+
@ide_client = ide_client
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def execute(**params)
|
|
25
|
+
unless @ide_client
|
|
26
|
+
return 'IDE symbols are only available when running inside VS Code.'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
query = params[:query] || ''
|
|
30
|
+
return 'Query is required.' if query.empty?
|
|
31
|
+
|
|
32
|
+
result = @ide_client.request('ide/getWorkspaceSymbols', { query: query }, timeout: 10)
|
|
33
|
+
symbols = result['symbols'] || []
|
|
34
|
+
|
|
35
|
+
return "No symbols found matching '#{query}'." if symbols.empty?
|
|
36
|
+
|
|
37
|
+
lines = symbols.first(50).map do |s|
|
|
38
|
+
container = s['containerName'] ? " (in #{s['containerName']})" : ''
|
|
39
|
+
line_info = s['line'] ? ":#{s['line']}" : ''
|
|
40
|
+
"#{s['kind']} #{s['name']}#{container} — #{s['file']}#{line_info}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
header = "Found #{symbols.size} symbol(s) matching '#{query}':"
|
|
44
|
+
([header] + lines).join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.summarize(output, _args)
|
|
48
|
+
first_line = output.lines.first&.strip || ''
|
|
49
|
+
first_line.start_with?('Found') ? first_line : ''
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -7,9 +7,11 @@ module RubynCode
|
|
|
7
7
|
module Tools
|
|
8
8
|
class LoadSkill < Base
|
|
9
9
|
TOOL_NAME = 'load_skill'
|
|
10
|
-
DESCRIPTION = 'Loads a skill document into
|
|
10
|
+
DESCRIPTION = 'Loads a best-practice skill document into context. ' \
|
|
11
|
+
'Pass the skill name (e.g. "shared-examples", "adapter", "request-specs").'
|
|
11
12
|
PARAMETERS = {
|
|
12
|
-
name: { type: :string, required: true,
|
|
13
|
+
name: { type: :string, required: true,
|
|
14
|
+
description: 'Skill name, e.g. "adapter", "shared-examples", "request-specs"' }
|
|
13
15
|
}.freeze
|
|
14
16
|
RISK_LEVEL = :read
|
|
15
17
|
REQUIRES_CONFIRMATION = false
|
|
@@ -20,18 +22,23 @@ module RubynCode
|
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def execute(name:)
|
|
25
|
+
# Strip leading slash — LLM sometimes sends /skill-name
|
|
26
|
+
cleaned = name.to_s.sub(%r{\A/+}, '').strip
|
|
27
|
+
return 'Error: skill name required' if cleaned.empty?
|
|
28
|
+
|
|
23
29
|
loader = @skill_loader || default_loader
|
|
24
|
-
loader.load(
|
|
30
|
+
loader.load(cleaned)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
private
|
|
28
34
|
|
|
29
35
|
def default_loader
|
|
30
36
|
skills_dirs = [
|
|
31
|
-
File.
|
|
32
|
-
File.join(
|
|
37
|
+
File.expand_path('../../../skills', __dir__), # bundled gem skills
|
|
38
|
+
File.join(project_root, '.rubyn-code', 'skills'), # project skills
|
|
39
|
+
File.join(Dir.home, '.rubyn-code', 'skills') # global user skills
|
|
33
40
|
]
|
|
34
|
-
catalog = Skills::Catalog.new(skills_dirs)
|
|
41
|
+
catalog = Skills::Catalog.new(skills_dirs.select { |d| Dir.exist?(d) })
|
|
35
42
|
Skills::Loader.new(catalog)
|
|
36
43
|
end
|
|
37
44
|
end
|
|
@@ -14,7 +14,8 @@ module RubynCode
|
|
|
14
14
|
query: { type: :string, required: true, description: 'Search query for finding relevant memories' },
|
|
15
15
|
tier: { type: :string, required: false, description: 'Filter by memory tier: short, medium, or long' },
|
|
16
16
|
category: { type: :string, required: false,
|
|
17
|
-
description: 'Filter by category: code_pattern, user_preference,
|
|
17
|
+
description: 'Filter by category: code_pattern, user_preference, ' \
|
|
18
|
+
'project_convention, error_resolution, or decision' },
|
|
18
19
|
limit: { type: :integer, required: false, description: 'Maximum number of results to return (default 10)' }
|
|
19
20
|
}.freeze
|
|
20
21
|
RISK_LEVEL = :read
|
|
@@ -49,21 +50,21 @@ module RubynCode
|
|
|
49
50
|
# @return [String]
|
|
50
51
|
def format_results(records)
|
|
51
52
|
lines = ["Found #{records.size} memor#{records.size == 1 ? 'y' : 'ies'}:\n"]
|
|
52
|
-
|
|
53
|
-
records.each_with_index do |record, idx|
|
|
54
|
-
lines << "--- Memory #{idx + 1} ---"
|
|
55
|
-
lines << "ID: #{record.id}"
|
|
56
|
-
lines << "Tier: #{record.tier} | Category: #{record.category || 'none'}"
|
|
57
|
-
lines << "Relevance: #{format('%.2f', record.relevance_score)} | Accessed: #{record.access_count} times"
|
|
58
|
-
lines << "Created: #{record.created_at}"
|
|
59
|
-
lines << ''
|
|
60
|
-
lines << record.content
|
|
61
|
-
lines << ''
|
|
62
|
-
end
|
|
63
|
-
|
|
53
|
+
records.each_with_index { |record, idx| lines.concat(format_single_memory(record, idx)) }
|
|
64
54
|
lines.join("\n")
|
|
65
55
|
end
|
|
66
56
|
|
|
57
|
+
def format_single_memory(record, idx)
|
|
58
|
+
[
|
|
59
|
+
"--- Memory #{idx + 1} ---",
|
|
60
|
+
"ID: #{record.id}",
|
|
61
|
+
"Tier: #{record.tier} | Category: #{record.category || 'none'}",
|
|
62
|
+
"Relevance: #{format('%.2f', record.relevance_score)} | Accessed: #{record.access_count} times",
|
|
63
|
+
"Created: #{record.created_at}",
|
|
64
|
+
'', record.content, ''
|
|
65
|
+
]
|
|
66
|
+
end
|
|
67
|
+
|
|
67
68
|
# Lazily resolves a Memory::Search instance from the project root.
|
|
68
69
|
#
|
|
69
70
|
# @return [Memory::Search]
|
|
@@ -15,7 +15,8 @@ module RubynCode
|
|
|
15
15
|
tier: { type: :string, required: false,
|
|
16
16
|
description: 'Memory retention tier: short, medium (default), or long' },
|
|
17
17
|
category: { type: :string, required: false,
|
|
18
|
-
description: 'Category: code_pattern, user_preference,
|
|
18
|
+
description: 'Category: code_pattern, user_preference, ' \
|
|
19
|
+
'project_convention, error_resolution, or decision' }
|
|
19
20
|
}.freeze
|
|
20
21
|
RISK_LEVEL = :read # Memory is internal — no user approval needed
|
|
21
22
|
|