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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. 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
- tool_class = Registry.get(tool_name)
17
- tool = tool_class.new(project_root: project_root)
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
- symbolized = params.transform_keys(&:to_sym)
23
- # Filter to only params the tool's execute method accepts — LLM may send extra keys
24
- allowed = tool.method(:execute).parameters
25
- .select { |type, _| %i[key keyreq].include?(type) }
26
- .map(&:last)
27
- filtered = allowed.empty? ? symbolized : symbolized.slice(*allowed)
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 inject_dependencies(tool, tool_name)
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.llm_client = @llm_client if tool.respond_to?(:llm_client=)
52
- tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
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
- raise Error, "Commit failed: #{stderr.strip}"
65
- end
63
+ format_commit_output(stdout)
64
+ end
66
65
 
67
- # Extract the commit hash from the output
68
- commit_hash = extract_commit_hash
69
- branch = current_branch
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
- lines = ["Committed on branch: #{branch}"]
72
- lines << "Commit: #{commit_hash}" if commit_hash
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
- count = [[count.to_i, 1].max, 200].min
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
- if stdout.strip.empty?
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
- private
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: { type: :string, required: true, description: "Glob pattern (e.g. '**/*.rb', 'app/**/*.erb')" },
13
- path: { type: :string, required: false, description: 'Directory to search in (defaults to project root)' }
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 execute(pattern:, path: nil)
19
- search_dir = path ? safe_path(path) : project_root
20
-
21
- raise Error, "Directory not found: #{path || project_root}" unless File.directory?(search_dir)
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| (File.basename(f).start_with?('.') && File.basename(f) == '.') || File.basename(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. Returns matching lines with file paths and line numbers.'
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 the conversation context. Use /skill-name or provide the skill name.'
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, description: 'Name of the skill to load' }
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(name)
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.join(project_root, '.rubyn', 'skills'),
32
- File.join(Dir.home, '.rubyn', 'skills')
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, project_convention, error_resolution, or decision' },
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, project_convention, error_resolution, or decision' }
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