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
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
break
|
|
54
|
-
end
|
|
43
|
+
Summarizer.call(state[:final_text])
|
|
44
|
+
end
|
|
55
45
|
|
|
56
|
-
|
|
46
|
+
private
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
53
|
+
if tool_calls.empty?
|
|
54
|
+
state[:final_text] = text_content
|
|
55
|
+
return :done
|
|
62
56
|
end
|
|
63
57
|
|
|
64
|
-
|
|
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 }
|
data/lib/rubyn_code/tasks/dag.rb
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
116
|
+
all_nodes = collect_all_nodes
|
|
117
|
+
in_degree = compute_in_degrees(all_nodes)
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
queue << dependent if
|
|
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 (
|
|
@@ -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}\
|
|
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,
|
|
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
|
-
"
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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.
|
|
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,
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|