rubyn-code 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -7,11 +7,15 @@ module RubynCode
7
7
  module Tools
8
8
  class EditFile < Base
9
9
  TOOL_NAME = 'edit_file'
10
- DESCRIPTION = 'Performs exact string replacement in a file. Fails if old_text is not found or is ambiguous.'
10
+ DESCRIPTION = 'Performs exact string replacement in a file. ' \
11
+ 'Fails if old_text is not found or is ambiguous.'
11
12
  PARAMETERS = {
12
- path: { type: :string, required: true, description: 'Path to the file to edit' },
13
- old_text: { type: :string, required: true, description: 'The exact text to find and replace' },
14
- new_text: { type: :string, required: true, description: 'The replacement text' },
13
+ path: { type: :string, required: true,
14
+ description: 'Path to the file to edit' },
15
+ old_text: { type: :string, required: true,
16
+ description: 'The exact text to find and replace' },
17
+ new_text: { type: :string, required: true,
18
+ description: 'The replacement text' },
15
19
  replace_all: { type: :boolean, required: false, default: false,
16
20
  description: 'Replace all occurrences (default: false)' }
17
21
  }.freeze
@@ -22,25 +26,77 @@ module RubynCode
22
26
  resolved = read_file_safely(path)
23
27
  content = File.read(resolved)
24
28
 
25
- occurrences = content.scan(old_text).length
29
+ validate_occurrences!(path, content, old_text, replace_all)
26
30
 
27
- raise Error, "old_text not found in #{path}. No changes made." if occurrences.zero?
31
+ new_content = apply_replacement(content, old_text, new_text, replace_all)
32
+ File.write(resolved, new_content)
28
33
 
29
- if !replace_all && occurrences > 1
30
- raise Error,
31
- "old_text found #{occurrences} times in #{path}. Use replace_all: true to replace all, or provide a more specific old_text."
32
- end
34
+ format_diff_result(path, content, old_text, new_text, replace_all)
35
+ end
33
36
 
34
- new_content = if replace_all
35
- content.gsub(old_text, new_text)
36
- else
37
- content.sub(old_text, new_text)
38
- end
37
+ private
39
38
 
40
- File.write(resolved, new_content)
39
+ def validate_occurrences!(path, content, old_text, replace_all)
40
+ count = content.scan(old_text).length
41
+
42
+ raise Error, "old_text not found in #{path}. No changes made." if count.zero?
43
+
44
+ return if replace_all || count == 1
45
+
46
+ raise Error,
47
+ "old_text found #{count} times in #{path}. " \
48
+ 'Use replace_all: true or provide more specific old_text.'
49
+ end
50
+
51
+ def apply_replacement(content, old_text, new_text, replace_all)
52
+ replace_all ? content.gsub(old_text, new_text) : content.sub(old_text, new_text)
53
+ end
54
+
55
+ CONTEXT_LINES = 3 # rubocop:disable Lint/UselessConstantScoping
56
+
57
+ def format_diff_result(path, original, old_text, new_text, replace_all)
58
+ count = replace_all ? original.scan(old_text).length : 1
59
+ lines = diff_header(path, count, original, old_text)
60
+ lines.concat(diff_body(original, old_text, new_text))
61
+ lines.join("\n")
62
+ end
63
+
64
+ def diff_header(path, count, original, old_text)
65
+ line_num = find_line_number(original, old_text)
66
+ header = ["Edited #{path} (#{count} replacement#{'s' if count > 1})"]
67
+ header << " @@ line #{line_num} @@" if line_num
68
+ header
69
+ end
70
+
71
+ def diff_body(original, old_text, new_text)
72
+ lines = context_before(original, old_text)
73
+ old_text.lines.each { |l| lines << " - #{l.chomp}" }
74
+ new_text.lines.each { |l| lines << " + #{l.chomp}" }
75
+ lines.concat(context_after(original, old_text))
76
+ end
77
+
78
+ def context_before(content, text)
79
+ idx = content.index(text)
80
+ return [] unless idx
81
+
82
+ before = content[0...idx].lines.last(CONTEXT_LINES)
83
+ before.map { |l| " #{l.chomp}" }
84
+ end
85
+
86
+ def context_after(content, text)
87
+ idx = content.index(text)
88
+ return [] unless idx
89
+
90
+ after_start = idx + text.length
91
+ after = content[after_start..].lines.first(CONTEXT_LINES)
92
+ after.map { |l| " #{l.chomp}" }
93
+ end
94
+
95
+ def find_line_number(content, text)
96
+ idx = content.index(text)
97
+ return nil unless idx
41
98
 
42
- replaced_count = replace_all ? occurrences : 1
43
- "Successfully replaced #{replaced_count} occurrence#{'s' if replaced_count > 1} in #{path}"
99
+ content[0...idx].count("\n") + 1
44
100
  end
45
101
  end
46
102
 
@@ -3,30 +3,27 @@
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
8
 
9
9
  def initialize(project_root:)
10
10
  @project_root = File.expand_path(project_root)
11
11
  @injections = {}
12
+ @output_compressor = OutputCompressor.new
13
+ @file_cache = FileCache.new
12
14
  Registry.load_all!
13
15
  end
14
16
 
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)
17
+ def execute(tool_name, params) # rubocop:disable Metrics/AbcSize -- maps tool errors to results
18
+ # File cache intercept: serve cached reads, invalidate on writes
19
+ cached = try_file_cache(tool_name, params)
20
+ return cached if cached
21
21
 
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)
22
+ tool = build_tool(tool_name)
23
+ filtered = filter_params(tool, params)
24
+ raw = tool.truncate(tool.execute(**filtered).to_s)
25
+ update_file_cache(tool_name, filtered, raw)
26
+ @output_compressor.compress(tool_name, raw)
30
27
  rescue ToolNotFoundError => e
31
28
  error_result("Tool error: #{e.message}")
32
29
  rescue PermissionDeniedError => e
@@ -45,20 +42,73 @@ module RubynCode
45
42
 
46
43
  private
47
44
 
48
- def inject_dependencies(tool, tool_name)
45
+ def build_tool(tool_name)
46
+ tool_class = Registry.get(tool_name)
47
+ tool = tool_class.new(project_root: project_root)
48
+ inject_dependencies(tool, tool_name)
49
+ tool
50
+ end
51
+
52
+ def filter_params(tool, params)
53
+ symbolized = params.transform_keys(&:to_sym)
54
+ allowed = tool.method(:execute).parameters
55
+ .select { |type, _| %i[key keyreq].include?(type) } # rubocop:disable Style/HashSlice
56
+ .map(&:last)
57
+ allowed.empty? ? symbolized : symbolized.slice(*allowed)
58
+ end
59
+
60
+ def inject_dependencies(tool, tool_name) # rubocop:disable Metrics/CyclomaticComplexity -- tool-specific dependency injection
49
61
  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=)
62
+ when 'spawn_agent', 'spawn_teammate'
63
+ inject_agent_deps(tool)
64
+ tool.db = @db if tool_name == 'spawn_teammate' && tool.respond_to?(:db=)
57
65
  when 'background_run'
58
66
  tool.background_worker = @background_worker if tool.respond_to?(:background_worker=)
67
+ when 'ask_user'
68
+ tool.prompt_callback = @ask_user_callback if tool.respond_to?(:prompt_callback=)
59
69
  end
60
70
  end
61
71
 
72
+ def inject_agent_deps(tool)
73
+ tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
74
+ tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
75
+ end
76
+
77
+ # Serve read_file from cache if the file hasn't changed.
78
+ def try_file_cache(tool_name, params)
79
+ return nil unless tool_name == 'read_file'
80
+
81
+ path = resolve_cache_path(params)
82
+ return nil unless path && @file_cache.cached?(path)
83
+
84
+ result = @file_cache.read(path)
85
+ result[:content]
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ # Cache read_file results; invalidate on write_file/edit_file.
91
+ def update_file_cache(tool_name, params, _raw)
92
+ path = resolve_cache_path(params)
93
+ return unless path
94
+
95
+ case tool_name
96
+ when 'read_file' then @file_cache.read(path) # populates cache
97
+ when 'write_file', 'edit_file' then @file_cache.on_write(path)
98
+ end
99
+ rescue StandardError
100
+ nil
101
+ end
102
+
103
+ def resolve_cache_path(params)
104
+ p = params[:path] || params['path']
105
+ return nil unless p
106
+
107
+ File.expand_path(p, @project_root)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+
62
112
  def error_result(message)
63
113
  message
64
114
  end
@@ -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,45 @@ 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
24
  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)
22
-
25
+ search_dir = resolve_search_dir(path)
23
26
  full_pattern = File.join(search_dir, pattern)
24
27
  matches = Dir.glob(full_pattern, File::FNM_DOTMATCH).sort
25
28
 
26
29
  matches
27
30
  .select { |f| File.file?(f) }
28
- .reject { |f| (File.basename(f).start_with?('.') && File.basename(f) == '.') || File.basename(f) == '..' }
31
+ .reject { |f| dot_entry?(f) }
29
32
  .map { |f| relative_to_root(f) }
30
33
  .join("\n")
31
34
  end
32
35
 
33
36
  private
34
37
 
38
+ def resolve_search_dir(path)
39
+ search_dir = path ? safe_path(path) : project_root
40
+
41
+ raise Error, "Directory not found: #{path || project_root}" unless File.directory?(search_dir)
42
+
43
+ search_dir
44
+ end
45
+
46
+ def dot_entry?(file)
47
+ basename = File.basename(file)
48
+ ['.', '..'].include?(basename)
49
+ end
50
+
35
51
  def relative_to_root(absolute_path)
36
52
  absolute_path.delete_prefix("#{project_root}/")
37
53
  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,
@@ -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