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
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Skills
5
+ # Manages skill TTL (time-to-live) and size caps. Skills injected into
6
+ # context are tracked with a turn counter; once a skill exceeds its
7
+ # TTL it is marked for ejection during the next compaction pass.
8
+ class TtlManager
9
+ DEFAULT_TTL = 5 # turns
10
+ MAX_SKILL_TOKENS = 800 # tokens
11
+ CHARS_PER_TOKEN = 4
12
+
13
+ Entry = Data.define(:name, :loaded_at_turn, :ttl, :token_count, :last_referenced_turn) do
14
+ def expired?(current_turn)
15
+ current_turn - last_referenced_turn > ttl
16
+ end
17
+ end
18
+
19
+ attr_reader :entries
20
+
21
+ def initialize
22
+ @entries = {}
23
+ @current_turn = 0
24
+ end
25
+
26
+ # Advance the turn counter. Call this once per agent loop iteration.
27
+ def tick!
28
+ @current_turn += 1
29
+ end
30
+
31
+ # Register a loaded skill with optional TTL override.
32
+ #
33
+ # @param name [String] skill name
34
+ # @param content [String] skill content
35
+ # @param ttl [Integer] turns before expiry (default 5)
36
+ # @return [String] content, possibly truncated to size cap
37
+ def register(name, content, ttl: DEFAULT_TTL)
38
+ truncated = enforce_size_cap(content)
39
+ token_count = estimate_tokens(truncated)
40
+
41
+ @entries[name] = Entry.new(
42
+ name: name,
43
+ loaded_at_turn: @current_turn,
44
+ ttl: ttl,
45
+ token_count: token_count,
46
+ last_referenced_turn: @current_turn
47
+ )
48
+
49
+ truncated
50
+ end
51
+
52
+ # Mark a skill as recently referenced (resets its TTL countdown).
53
+ def touch(name)
54
+ return unless @entries.key?(name)
55
+
56
+ @entries[name] = @entries[name].with(last_referenced_turn: @current_turn)
57
+ end
58
+
59
+ # Returns names of skills that have exceeded their TTL.
60
+ def expired_skills
61
+ @entries.select { |_, entry| entry.expired?(@current_turn) }.keys
62
+ end
63
+
64
+ # Remove expired skills and return their names.
65
+ def eject_expired!
66
+ expired = expired_skills
67
+ expired.each { |name| @entries.delete(name) }
68
+ expired
69
+ end
70
+
71
+ # Returns total tokens used by currently loaded skills.
72
+ def total_tokens
73
+ @entries.values.sum(&:token_count)
74
+ end
75
+
76
+ # Returns stats for the analytics dashboard.
77
+ def stats
78
+ {
79
+ loaded_skills: @entries.size,
80
+ total_tokens: total_tokens,
81
+ expired: expired_skills.size,
82
+ current_turn: @current_turn
83
+ }
84
+ end
85
+
86
+ private
87
+
88
+ def enforce_size_cap(content)
89
+ max_chars = MAX_SKILL_TOKENS * CHARS_PER_TOKEN
90
+ return content if content.length <= max_chars
91
+
92
+ content[0, max_chars] + "\n... [skill truncated to #{MAX_SKILL_TOKENS} tokens]"
93
+ end
94
+
95
+ def estimate_tokens(text)
96
+ (text.bytesize.to_f / CHARS_PER_TOKEN).ceil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -32,40 +32,35 @@ module RubynCode
32
32
  end
33
33
 
34
34
  def run
35
- conversation = build_conversation
36
- executor = build_executor
37
- tool_defs = build_tool_definitions
35
+ state = { conversation: build_conversation, executor: build_executor,
36
+ tool_defs: build_tool_definitions, final_text: '' }
38
37
 
39
- iteration = 0
40
- final_text = ''
41
-
42
- loop do
43
- break if iteration >= @max_iterations
44
-
45
- response = request_llm(conversation, tool_defs)
46
- iteration += 1
47
-
48
- text_content = extract_text(response)
49
- tool_calls = extract_tool_calls(response)
38
+ @max_iterations.times do
39
+ result = run_single_iteration(state)
40
+ break if result == :done
41
+ end
50
42
 
51
- if tool_calls.empty?
52
- final_text = text_content
53
- break
54
- end
43
+ Summarizer.call(state[:final_text])
44
+ end
55
45
 
56
- conversation << { role: 'assistant', content: response }
46
+ private
57
47
 
58
- tool_results = execute_tools(executor, tool_calls)
59
- conversation << { role: 'user', content: tool_results }
48
+ def run_single_iteration(state)
49
+ response = request_llm(state[:conversation], state[:tool_defs])
50
+ text_content = extract_text(response)
51
+ tool_calls = extract_tool_calls(response)
60
52
 
61
- final_text = text_content unless text_content.empty?
53
+ if tool_calls.empty?
54
+ state[:final_text] = text_content
55
+ return :done
62
56
  end
63
57
 
64
- Summarizer.call(final_text)
58
+ state[:conversation] << { role: 'assistant', content: response }
59
+ state[:conversation] << { role: 'user', content: execute_tools(state[:executor], tool_calls) }
60
+ state[:final_text] = text_content unless text_content.empty?
61
+ :continue
65
62
  end
66
63
 
67
- private
68
-
69
64
  def build_conversation
70
65
  [
71
66
  { role: 'user', content: @prompt }
@@ -113,30 +113,35 @@ module RubynCode
113
113
  # @return [Array<String>]
114
114
  # @raise [RuntimeError] if the graph contains a cycle
115
115
  def topological_sort
116
- in_degree = Hash.new(0)
117
- all_nodes = Set.new
116
+ all_nodes = collect_all_nodes
117
+ in_degree = compute_in_degrees(all_nodes)
118
118
 
119
- @forward.each do |task_id, deps|
120
- all_nodes.add(task_id)
121
- deps.each do |dep_id|
122
- all_nodes.add(dep_id)
123
- in_degree[dep_id] # touch to initialize
124
- in_degree[task_id] += 1 # task_id depends on dep_id, so task_id has higher in-degree
125
- end
126
- end
119
+ sorted = kahn_sort(all_nodes, in_degree)
120
+ raise 'Cycle detected in task dependency graph' if sorted.size != all_nodes.size
127
121
 
128
- # Nodes with no dependencies come first
129
- # Note: in our graph, forward[task_id] = set of tasks task_id depends ON,
130
- # so the "edges" for topological sort point from dep -> task_id.
131
- in_degree_corrected = Hash.new(0)
132
- all_nodes.each { |n| in_degree_corrected[n] = 0 }
122
+ sorted
123
+ end
124
+
125
+ private
133
126
 
127
+ def collect_all_nodes
128
+ nodes = Set.new
134
129
  @forward.each do |task_id, deps|
135
- # task_id depends on each dep, meaning dep must come before task_id
136
- in_degree_corrected[task_id] += deps.size
130
+ nodes.add(task_id)
131
+ deps.each { |dep_id| nodes.add(dep_id) }
137
132
  end
133
+ nodes
134
+ end
135
+
136
+ def compute_in_degrees(all_nodes)
137
+ in_degree = Hash.new(0)
138
+ all_nodes.each { |n| in_degree[n] = 0 }
139
+ @forward.each { |task_id, deps| in_degree[task_id] += deps.size }
140
+ in_degree
141
+ end
138
142
 
139
- queue = all_nodes.select { |n| in_degree_corrected[n].zero? }
143
+ def kahn_sort(all_nodes, in_degree)
144
+ queue = all_nodes.select { |n| in_degree[n].zero? }
140
145
  sorted = []
141
146
 
142
147
  until queue.empty?
@@ -144,18 +149,14 @@ module RubynCode
144
149
  sorted << node
145
150
 
146
151
  @reverse[node].each do |dependent|
147
- in_degree_corrected[dependent] -= 1
148
- queue << dependent if in_degree_corrected[dependent].zero?
152
+ in_degree[dependent] -= 1
153
+ queue << dependent if in_degree[dependent].zero?
149
154
  end
150
155
  end
151
156
 
152
- raise 'Cycle detected in task dependency graph' if sorted.size != all_nodes.size
153
-
154
157
  sorted
155
158
  end
156
159
 
157
- private
158
-
159
160
  def ensure_table
160
161
  @db.execute(<<~SQL)
161
162
  CREATE TABLE IF NOT EXISTS task_dependencies (
@@ -10,6 +10,7 @@ module RubynCode
10
10
  def in_progress? = status == 'in_progress'
11
11
  def completed? = status == 'completed'
12
12
  def blocked? = status == 'blocked'
13
+ def failed? = status == 'failed'
13
14
 
14
15
  def to_h
15
16
  {
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'registry'
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class AskUser < Base
9
+ TOOL_NAME = 'ask_user'
10
+ DESCRIPTION = 'Ask the user a question and wait for their response. ' \
11
+ 'Use this when you need clarification, want to confirm a plan before executing, ' \
12
+ 'or are stuck and need guidance. The question is displayed and the user\'s answer ' \
13
+ 'is returned as the tool result.'
14
+ PARAMETERS = {
15
+ question: {
16
+ type: :string,
17
+ description: 'The question to ask the user',
18
+ required: true
19
+ }
20
+ }.freeze
21
+ RISK_LEVEL = :read # Never needs approval — it IS the approval mechanism
22
+
23
+ attr_writer :prompt_callback
24
+
25
+ def execute(question:)
26
+ if @prompt_callback
27
+ @prompt_callback.call(question)
28
+ elsif $stdin.respond_to?(:tty?) && $stdin.tty?
29
+ # Interactive fallback: prompt on stdin
30
+ $stdout.puts
31
+ $stdout.puts " #{question}"
32
+ $stdout.print ' > '
33
+ $stdout.flush
34
+ $stdin.gets&.strip || '[no response]'
35
+ else
36
+ # Non-interactive (piped input, -p mode, daemon) — can't ask
37
+ '[non-interactive session — cannot ask user. Make your best judgment and proceed.]'
38
+ end
39
+ end
40
+ end
41
+
42
+ Registry.register(AskUser)
43
+ end
44
+ end
@@ -30,7 +30,8 @@ module RubynCode
30
30
  return 'Error: Background worker not available. Use bash tool instead.' unless @background_worker
31
31
 
32
32
  job_id = @background_worker.run(command, timeout: timeout)
33
- "Background job started: #{job_id}\nCommand: #{command}\nTimeout: #{timeout}s\nResults will appear automatically when complete."
33
+ "Background job started: #{job_id}\nCommand: #{command}\n" \
34
+ "Timeout: #{timeout}s\nResults will appear automatically when complete."
34
35
  end
35
36
  end
36
37
 
@@ -37,6 +37,19 @@ module RubynCode
37
37
  input_schema: Schema.build(parameters)
38
38
  }
39
39
  end
40
+
41
+ # One-line summary of a successful invocation, shown in the IDE's
42
+ # chat card. Default is empty so the UI renders a clean "Done"
43
+ # indicator. Override in subclasses that have a useful one-liner
44
+ # (e.g. "Edited app.rb (1 replacement)"). The full output still
45
+ # goes to the conversation untouched — this only affects the UI.
46
+ #
47
+ # @param output [String] what execute(**) returned
48
+ # @param args [Hash] the tool arguments (string-keyed)
49
+ # @return [String]
50
+ def summarize(_output, _args)
51
+ ''
52
+ end
40
53
  end
41
54
 
42
55
  attr_reader :project_root
@@ -57,7 +70,8 @@ module RubynCode
57
70
  end
58
71
 
59
72
  unless expanded.start_with?(project_root)
60
- raise PermissionDeniedError, "Path traversal denied: #{path} resolves outside project root"
73
+ raise PermissionDeniedError,
74
+ "Path traversal denied: #{path} resolves outside project root"
61
75
  end
62
76
 
63
77
  expanded
@@ -67,7 +81,8 @@ module RubynCode
67
81
  return output if output.nil? || output.length <= max
68
82
 
69
83
  half = max / 2
70
- "#{output[0, half]}\n\n... [truncated #{output.length - max} characters] ...\n\n#{output[-half, half]}"
84
+ middle = "\n\n... [truncated #{output.length - max} characters] ...\n\n"
85
+ "#{output[0, half]}#{middle}#{output[-half, half]}"
71
86
  end
72
87
 
73
88
  private
@@ -82,34 +97,30 @@ module RubynCode
82
97
  stdout = +''
83
98
  stderr = +''
84
99
 
85
- out_reader = Thread.new do
86
- stdout << stdout_io.read
87
- rescue StandardError
88
- nil
89
- end
90
- err_reader = Thread.new do
91
- stderr << stderr_io.read
92
- rescue StandardError
93
- nil
94
- end
100
+ out_reader = Thread.new { stdout << stdout_io.read rescue nil } # rubocop:disable Style/RescueModifier
101
+ err_reader = Thread.new { stderr << stderr_io.read rescue nil } # rubocop:disable Style/RescueModifier
95
102
 
96
- timed_out = false
97
- unless wait_thr.join(timeout)
98
- timed_out = true
99
- begin
100
- Process.kill('TERM', wait_thr.pid)
101
- rescue StandardError
102
- nil
103
- end
104
- sleep 0.1
105
- begin
106
- Process.kill('KILL', wait_thr.pid)
107
- rescue StandardError
108
- nil
109
- end
110
- wait_thr.join(5)
111
- end
103
+ wait_for_process(wait_thr, timeout)
104
+ finalize_readers(out_reader, err_reader, stdout_io, stderr_io)
105
+
106
+ [stdout, stderr, wait_thr.value]
107
+ end
108
+
109
+ def wait_for_process(wait_thr, timeout)
110
+ return if wait_thr.join(timeout)
111
+
112
+ kill_process(wait_thr.pid)
113
+ wait_thr.join(5)
114
+ raise Error, "Command timed out after #{timeout}s"
115
+ end
112
116
 
117
+ def kill_process(pid)
118
+ Process.kill('TERM', pid) rescue nil # rubocop:disable Style/RescueModifier
119
+ sleep 0.1
120
+ Process.kill('KILL', pid) rescue nil # rubocop:disable Style/RescueModifier
121
+ end
122
+
123
+ def finalize_readers(out_reader, err_reader, stdout_io, stderr_io)
113
124
  out_reader.join(5)
114
125
  err_reader.join(5)
115
126
  [stdout_io, stderr_io].each do |io|
@@ -117,10 +128,6 @@ module RubynCode
117
128
  rescue StandardError
118
129
  nil
119
130
  end
120
-
121
- raise Error, "Command timed out after #{timeout}s" if timed_out
122
-
123
- [stdout, stderr, wait_thr.value]
124
131
  end
125
132
 
126
133
  def read_file_safely(path)
@@ -9,7 +9,8 @@ module RubynCode
9
9
  module Tools
10
10
  class Bash < Base
11
11
  TOOL_NAME = 'bash'
12
- DESCRIPTION = 'Runs a shell command in the project directory. Blocks dangerous patterns and scrubs sensitive environment variables.'
12
+ DESCRIPTION = 'Runs a shell command in the project directory. Blocks dangerous patterns ' \
13
+ 'and scrubs sensitive environment variables.'
13
14
  PARAMETERS = {
14
15
  command: { type: :string, required: true, description: 'The shell command to execute' },
15
16
  timeout: { type: :integer, required: false, default: 120, description: 'Timeout in seconds (default: 120)' }
@@ -17,6 +18,11 @@ module RubynCode
17
18
  RISK_LEVEL = :execute
18
19
  REQUIRES_CONFIRMATION = true
19
20
 
21
+ def self.summarize(_output, args)
22
+ cmd = args['command'] || args[:command] || ''
23
+ "$ #{cmd[0, 180]}"
24
+ end
25
+
20
26
  def execute(command:, timeout: 120)
21
27
  validate_command!(command)
22
28
 
@@ -7,40 +7,153 @@ 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
18
22
  RISK_LEVEL = :write
19
23
  REQUIRES_CONFIRMATION = false
20
24
 
25
+ # Take the first line of the tool's output, which is already formatted
26
+ # as "Edited /path.rb (N replacements)".
27
+ def self.summarize(output, _args)
28
+ output.to_s.lines.first.to_s.chomp[0, 200]
29
+ end
30
+
21
31
  def execute(path:, old_text:, new_text:, replace_all: false)
22
32
  resolved = read_file_safely(path)
23
33
  content = File.read(resolved)
24
34
 
25
- occurrences = content.scan(old_text).length
35
+ validate_occurrences!(path, content, old_text, replace_all)
36
+
37
+ new_content = apply_replacement(content, old_text, new_text, replace_all)
38
+ File.write(resolved, new_content)
39
+
40
+ format_diff_result(path, content, old_text, new_text, replace_all)
41
+ end
26
42
 
27
- raise Error, "old_text not found in #{path}. No changes made." if occurrences.zero?
43
+ # Compute the proposed file content without writing to disk.
44
+ # Used by IDE mode to preview the edit in a diff view before the user
45
+ # accepts. Raises if old_text is missing or ambiguous, same as execute.
46
+ #
47
+ # @return [Hash] { content: String, type: 'modify' }
48
+ def preview_content(path:, old_text:, new_text:, replace_all: false)
49
+ resolved = read_file_safely(path)
50
+ content = File.read(resolved)
51
+
52
+ validate_occurrences!(path, content, old_text, replace_all)
53
+
54
+ { content: apply_replacement(content, old_text, new_text, replace_all), type: 'modify' }
55
+ end
28
56
 
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."
57
+ private
58
+
59
+ def validate_occurrences!(path, content, old_text, replace_all)
60
+ count = content.scan(old_text).length
61
+
62
+ # If exact match fails, try with normalized trailing whitespace on
63
+ # each line. Models sometimes strip or add trailing spaces/tabs.
64
+ if count.zero?
65
+ normalized_content = normalize_trailing_ws(content)
66
+ normalized_old = normalize_trailing_ws(old_text)
67
+ count = normalized_content.scan(normalized_old).length
68
+
69
+ raise Error, "old_text not found in #{path}. No changes made." if count.zero?
32
70
  end
33
71
 
34
- new_content = if replace_all
35
- content.gsub(old_text, new_text)
36
- else
37
- content.sub(old_text, new_text)
38
- end
72
+ return if replace_all || count == 1
39
73
 
40
- File.write(resolved, new_content)
74
+ raise Error,
75
+ "old_text found #{count} times in #{path}. " \
76
+ 'Use replace_all: true or provide more specific old_text.'
77
+ end
78
+
79
+ def apply_replacement(content, old_text, new_text, replace_all)
80
+ # Try exact match first
81
+ if content.include?(old_text)
82
+ return replace_all ? content.gsub(old_text, new_text) : content.sub(old_text, new_text)
83
+ end
84
+
85
+ # Fall back to normalized trailing-whitespace match
86
+ normalized_content = normalize_trailing_ws(content)
87
+ normalized_old = normalize_trailing_ws(old_text)
88
+
89
+ if normalized_content.include?(normalized_old)
90
+ if replace_all
91
+ normalized_content.gsub(normalized_old, new_text)
92
+ else
93
+ normalized_content.sub(normalized_old, new_text)
94
+ end
95
+ else
96
+ content.sub(old_text, new_text)
97
+ end
98
+ end
99
+
100
+ def normalize_trailing_ws(str)
101
+ str.gsub(/[^\S\n]+$/, '')
102
+ end
103
+
104
+ CONTEXT_LINES = 3 # rubocop:disable Lint/UselessConstantScoping
105
+
106
+ def format_diff_result(path, original, old_text, new_text, replace_all)
107
+ count = replace_all ? original.scan(old_text).length : 1
108
+ lines = diff_header(path, count, original, old_text)
109
+ lines.concat(diff_body(original, old_text, new_text))
110
+ lines.join("\n")
111
+ end
112
+
113
+ def diff_header(path, count, original, old_text)
114
+ line_num = find_line_number(original, old_text)
115
+ header = ["Edited #{path} (#{count} replacement#{'s' if count > 1})"]
116
+ header << " @@ line #{line_num} @@" if line_num
117
+ header
118
+ end
119
+
120
+ def diff_body(original, old_text, new_text)
121
+ lines = context_before(original, old_text)
122
+ old_text.lines.each { |l| lines << " - #{l.chomp}" }
123
+ new_text.lines.each { |l| lines << " + #{l.chomp}" }
124
+ lines.concat(context_after(original, old_text))
125
+ end
126
+
127
+ def context_before(content, text)
128
+ idx = find_index(content, text)
129
+ return [] unless idx
130
+
131
+ before = content[0...idx].lines.last(CONTEXT_LINES)
132
+ before.map { |l| " #{l.chomp}" }
133
+ end
134
+
135
+ def context_after(content, text)
136
+ idx = find_index(content, text)
137
+ return [] unless idx
138
+
139
+ after_start = idx + text.length
140
+ after = content[after_start..].lines.first(CONTEXT_LINES)
141
+ after.map { |l| " #{l.chomp}" }
142
+ end
143
+
144
+ def find_line_number(content, text)
145
+ idx = find_index(content, text)
146
+ return nil unless idx
147
+
148
+ content[0...idx].count("\n") + 1
149
+ end
150
+
151
+ # Find the index of text in content, falling back to normalized match.
152
+ def find_index(content, text)
153
+ idx = content.index(text)
154
+ return idx if idx
41
155
 
42
- replaced_count = replace_all ? occurrences : 1
43
- "Successfully replaced #{replaced_count} occurrence#{'s' if replaced_count > 1} in #{path}"
156
+ normalize_trailing_ws(content).index(normalize_trailing_ws(text))
44
157
  end
45
158
  end
46
159