rubyn-code 0.3.0 → 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 +77 -19
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +32 -3
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
- data/lib/rubyn_code/agent/llm_caller.rb +9 -1
- data/lib/rubyn_code/agent/loop.rb +7 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
- data/lib/rubyn_code/agent/tool_processor.rb +21 -1
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/token_store.rb +50 -9
- data/lib/rubyn_code/autonomous/daemon.rb +117 -14
- data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
- data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
- data/lib/rubyn_code/cli/app.rb +32 -1
- 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 +32 -2
- 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 +36 -0
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/repl.rb +6 -1
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +36 -0
- data/lib/rubyn_code/config/defaults.rb +1 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +7 -4
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +16 -1
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/manager.rb +37 -3
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- 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 +67 -1
- data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
- data/lib/rubyn_code/llm/client.rb +29 -4
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/base.rb +13 -0
- data/lib/rubyn_code/tools/bash.rb +5 -0
- data/lib/rubyn_code/tools/edit_file.rb +62 -5
- data/lib/rubyn_code/tools/executor.rb +61 -6
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +6 -0
- 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/output_compressor.rb +6 -1
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +22 -0
- data/skills/rubyn_self_test.md +13 -1
- metadata +31 -1
|
@@ -25,8 +25,9 @@ module RubynCode
|
|
|
25
25
|
|
|
26
26
|
attr_reader :loaded_files, :signature_files, :tokens_used
|
|
27
27
|
|
|
28
|
-
def initialize(budget: DEFAULT_BUDGET)
|
|
28
|
+
def initialize(budget: DEFAULT_BUDGET, codebase_index: nil)
|
|
29
29
|
@budget = budget
|
|
30
|
+
@codebase_index = codebase_index
|
|
30
31
|
@loaded_files = []
|
|
31
32
|
@signature_files = []
|
|
32
33
|
@tokens_used = 0
|
|
@@ -34,6 +35,10 @@ module RubynCode
|
|
|
34
35
|
|
|
35
36
|
# Load context for a primary file, filling budget with related files.
|
|
36
37
|
# Returns array of { file:, content:, mode: :full|:signatures }
|
|
38
|
+
#
|
|
39
|
+
# When a codebase_index is available and no related_files are supplied,
|
|
40
|
+
# uses impact_analysis to auto-discover related files (specs,
|
|
41
|
+
# associated models, controllers, etc.).
|
|
37
42
|
def load_for(file_path, related_files: [])
|
|
38
43
|
results = []
|
|
39
44
|
|
|
@@ -46,6 +51,9 @@ module RubynCode
|
|
|
46
51
|
@loaded_files << file_path
|
|
47
52
|
results << { file: file_path, content: primary_content, mode: :full }
|
|
48
53
|
|
|
54
|
+
# Auto-discover related files from the index when none supplied
|
|
55
|
+
related_files = discover_related_files(file_path) if related_files.empty? && @codebase_index
|
|
56
|
+
|
|
49
57
|
# Sort related files by priority and fill remaining budget
|
|
50
58
|
sorted = prioritize(related_files)
|
|
51
59
|
remaining = @budget - @tokens_used
|
|
@@ -81,6 +89,13 @@ module RubynCode
|
|
|
81
89
|
|
|
82
90
|
private
|
|
83
91
|
|
|
92
|
+
def discover_related_files(file_path)
|
|
93
|
+
analysis = @codebase_index.impact_analysis(file_path)
|
|
94
|
+
analysis[:affected_files].reject { |f| f == file_path }
|
|
95
|
+
rescue StandardError
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
84
99
|
def load_full_files(sorted, results, remaining)
|
|
85
100
|
sorted.each do |rel_path|
|
|
86
101
|
content = safe_read(rel_path)
|
|
@@ -23,13 +23,16 @@ module RubynCode
|
|
|
23
23
|
def self.call(messages, threshold:, keep_recent: 6)
|
|
24
24
|
return nil if messages.size <= keep_recent + 2
|
|
25
25
|
|
|
26
|
-
#
|
|
27
|
-
first
|
|
26
|
+
# Always preserve the very first message (may contain critical
|
|
27
|
+
# system-level context like auth shims) AND the first real user
|
|
28
|
+
# message so the agent retains the user's original request.
|
|
29
|
+
anchors = build_anchors(messages)
|
|
30
|
+
|
|
28
31
|
recent = messages.last(keep_recent)
|
|
29
|
-
snipped_count = messages.size - keep_recent -
|
|
32
|
+
snipped_count = messages.size - keep_recent - anchors.size
|
|
30
33
|
|
|
31
34
|
collapsed = [
|
|
32
|
-
|
|
35
|
+
*anchors,
|
|
33
36
|
{ role: 'user', content: format(SNIP_MARKER, snipped_count) },
|
|
34
37
|
*recent
|
|
35
38
|
]
|
|
@@ -40,6 +43,33 @@ module RubynCode
|
|
|
40
43
|
rescue JSON::GeneratorError
|
|
41
44
|
nil
|
|
42
45
|
end
|
|
46
|
+
|
|
47
|
+
# Builds the list of anchor messages to preserve at the top.
|
|
48
|
+
# Always keeps messages[0] (may contain critical system context).
|
|
49
|
+
# If messages[0] is a system injection, also keeps the first real
|
|
50
|
+
# user message so the agent retains the original request.
|
|
51
|
+
def self.build_anchors(messages)
|
|
52
|
+
first = messages.first
|
|
53
|
+
anchors = [first]
|
|
54
|
+
return anchors unless system_injection?(first)
|
|
55
|
+
|
|
56
|
+
user_msg = first_real_user_message(messages)
|
|
57
|
+
anchors << user_msg if user_msg
|
|
58
|
+
anchors
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.system_injection?(msg)
|
|
62
|
+
content = msg[:content]
|
|
63
|
+
content.is_a?(String) && content.start_with?('[system]')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.first_real_user_message(messages)
|
|
67
|
+
messages[1..].find do |msg|
|
|
68
|
+
msg[:role] == 'user' && !system_injection?(msg)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private_class_method :build_anchors
|
|
43
73
|
end
|
|
44
74
|
end
|
|
45
75
|
end
|
|
@@ -10,7 +10,7 @@ module RubynCode
|
|
|
10
10
|
class Manager
|
|
11
11
|
CHARS_PER_TOKEN = 4
|
|
12
12
|
|
|
13
|
-
attr_reader :total_input_tokens, :total_output_tokens
|
|
13
|
+
attr_reader :total_input_tokens, :total_output_tokens, :current_turn
|
|
14
14
|
|
|
15
15
|
# @param threshold [Integer] estimated token count that triggers auto-compaction
|
|
16
16
|
# @param llm_client [LLM::Client, nil] needed for LLM-driven compaction
|
|
@@ -19,10 +19,18 @@ module RubynCode
|
|
|
19
19
|
@llm_client = llm_client
|
|
20
20
|
@total_input_tokens = 0
|
|
21
21
|
@total_output_tokens = 0
|
|
22
|
+
@last_compaction_turn = -1
|
|
23
|
+
@current_turn = 0
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
attr_writer :llm_client
|
|
25
27
|
|
|
28
|
+
# Advances the turn counter. Call once per iteration so that
|
|
29
|
+
# duplicate compaction calls within the same turn are skipped.
|
|
30
|
+
def advance_turn!
|
|
31
|
+
@current_turn += 1
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
# Accumulates token counts from an LLM response usage object.
|
|
27
35
|
#
|
|
28
36
|
# @param usage [LLM::Usage, #input_tokens] usage data from an LLM response
|
|
@@ -60,16 +68,24 @@ module RubynCode
|
|
|
60
68
|
# Fraction of the compaction threshold at which micro-compact kicks in.
|
|
61
69
|
# Running it too early busts the prompt cache prefix (mutated messages
|
|
62
70
|
# change the hash, invalidating server-side cached tokens).
|
|
63
|
-
|
|
71
|
+
# Anthropic has prompt caching so we delay compaction (0.7).
|
|
72
|
+
# OpenAI has no cache prefix to protect so we compact earlier (0.5).
|
|
73
|
+
MICRO_COMPACT_RATIO_CACHED = 0.7
|
|
74
|
+
MICRO_COMPACT_RATIO_UNCACHED = 0.5
|
|
64
75
|
|
|
65
76
|
def check_compaction!(conversation)
|
|
77
|
+
# Guard: skip if compaction already ran this turn
|
|
78
|
+
return if @last_compaction_turn == @current_turn
|
|
79
|
+
|
|
80
|
+
@last_compaction_turn = @current_turn
|
|
81
|
+
|
|
66
82
|
messages = conversation.messages
|
|
67
83
|
|
|
68
84
|
# Step 1: Zero-cost micro-compact — but only when we're approaching
|
|
69
85
|
# the compaction threshold. Running it every turn mutates old messages,
|
|
70
86
|
# which invalidates the prompt cache prefix and wastes tokens.
|
|
71
87
|
est = estimated_tokens(messages)
|
|
72
|
-
MicroCompact.call(messages) if est > (@threshold *
|
|
88
|
+
MicroCompact.call(messages) if est > (@threshold * micro_compact_ratio)
|
|
73
89
|
|
|
74
90
|
return unless needs_compaction?(messages)
|
|
75
91
|
|
|
@@ -94,10 +110,28 @@ module RubynCode
|
|
|
94
110
|
def reset!
|
|
95
111
|
@total_input_tokens = 0
|
|
96
112
|
@total_output_tokens = 0
|
|
113
|
+
@last_compaction_turn = -1
|
|
114
|
+
@current_turn = 0
|
|
97
115
|
end
|
|
98
116
|
|
|
99
117
|
private
|
|
100
118
|
|
|
119
|
+
# Returns the micro-compact ratio based on the active provider.
|
|
120
|
+
# Providers with prompt caching (Anthropic) use a higher ratio to
|
|
121
|
+
# preserve cached prefixes; providers without caching compact earlier.
|
|
122
|
+
def micro_compact_ratio
|
|
123
|
+
return MICRO_COMPACT_RATIO_UNCACHED if uncached_provider?
|
|
124
|
+
|
|
125
|
+
MICRO_COMPACT_RATIO_CACHED
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def uncached_provider?
|
|
129
|
+
return false unless @llm_client
|
|
130
|
+
|
|
131
|
+
provider = @llm_client.provider_name if @llm_client.respond_to?(:provider_name)
|
|
132
|
+
%w[openai openai_compatible].include?(provider)
|
|
133
|
+
end
|
|
134
|
+
|
|
101
135
|
def apply_compacted_messages(conversation, new_messages)
|
|
102
136
|
if conversation.respond_to?(:replace_messages)
|
|
103
137
|
conversation.replace_messages(new_messages)
|
|
@@ -68,7 +68,7 @@ module RubynCode
|
|
|
68
68
|
]
|
|
69
69
|
|
|
70
70
|
options = {}
|
|
71
|
-
options[:model] = 'claude-sonnet-4-
|
|
71
|
+
options[:model] = 'claude-sonnet-4-6' if llm_client.respond_to?(:chat)
|
|
72
72
|
|
|
73
73
|
response = llm_client.chat(messages: summary_messages, **options)
|
|
74
74
|
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module IDE
|
|
7
|
+
module Adapters
|
|
8
|
+
# Wraps every tool invocation in IDE mode. Emits JSON-RPC notifications
|
|
9
|
+
# that the VS Code extension consumes, precomputes file edits so the
|
|
10
|
+
# editor can render a diff before any write touches disk, and gates
|
|
11
|
+
# mutating operations behind acceptance/approval from the IDE client.
|
|
12
|
+
#
|
|
13
|
+
# Gating policy depends on the permission mode:
|
|
14
|
+
# :default → approve every mutating tool + every file edit
|
|
15
|
+
# :accept_edits → auto-approve file edits, prompt for bash/other
|
|
16
|
+
# :plan_only → read-only, block all writes
|
|
17
|
+
# :auto → auto-approve everything except deny-listed
|
|
18
|
+
# :dont_ask → auto-deny all non-read-only tools
|
|
19
|
+
# :bypass → no checks (legacy yolo)
|
|
20
|
+
#
|
|
21
|
+
# In all modes the adapter emits notifications so the UI reflects
|
|
22
|
+
# what's happening.
|
|
23
|
+
class ToolOutput
|
|
24
|
+
# Wake up periodically to check for thread interrupts (cancel).
|
|
25
|
+
# No auto-deny — waits indefinitely until the user decides.
|
|
26
|
+
WAIT_POLL_INTERVAL = 5 # seconds
|
|
27
|
+
|
|
28
|
+
READ_ONLY_TOOLS = %w[
|
|
29
|
+
read_file glob grep
|
|
30
|
+
git_status git_diff git_log git_commit
|
|
31
|
+
memory_search web_fetch web_search
|
|
32
|
+
run_specs
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
FILE_WRITE_TOOLS = %w[write_file edit_file].freeze
|
|
36
|
+
|
|
37
|
+
VALID_PERMISSION_MODES = %i[default accept_edits plan_only auto dont_ask bypass].freeze
|
|
38
|
+
|
|
39
|
+
attr_accessor :permission_mode
|
|
40
|
+
|
|
41
|
+
def initialize(server, permission_mode: :default, yolo: false, hook_runner: nil)
|
|
42
|
+
@server = server
|
|
43
|
+
@permission_mode = yolo ? :bypass : permission_mode.to_sym
|
|
44
|
+
@hook_runner = hook_runner
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
|
|
47
|
+
# { request_id => { cv: ConditionVariable, approved: nil|true|false } }
|
|
48
|
+
@pending_approvals = {}
|
|
49
|
+
|
|
50
|
+
# { edit_id => { cv: ConditionVariable, accepted: nil|true|false } }
|
|
51
|
+
@pending_edits = {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Main entry point. Wraps a tool call, emitting IDE notifications
|
|
55
|
+
# and gating execution behind acceptance when required.
|
|
56
|
+
#
|
|
57
|
+
# adapter.wrap_execution("write_file", { path: "foo.rb", content: "..." }) do
|
|
58
|
+
# executor.execute("write_file", params)
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
def wrap_execution(tool_name, args, &block)
|
|
62
|
+
request_id = generate_id
|
|
63
|
+
args = stringify_keys(args)
|
|
64
|
+
|
|
65
|
+
return execute_and_notify(request_id, tool_name, args, &block) if read_only?(tool_name)
|
|
66
|
+
|
|
67
|
+
# Non-read-only tools: gating depends on permission mode
|
|
68
|
+
case @permission_mode
|
|
69
|
+
when :bypass, :auto
|
|
70
|
+
# Auto-approve everything — run without waiting
|
|
71
|
+
execute_and_notify(request_id, tool_name, args, &block)
|
|
72
|
+
when :plan_only
|
|
73
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: false)
|
|
74
|
+
msg = 'Plan mode: write operations blocked'
|
|
75
|
+
emit_tool_result(request_id, tool_name, msg, success: false, args: args)
|
|
76
|
+
raise RubynCode::UserDeniedError, msg
|
|
77
|
+
when :dont_ask
|
|
78
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: false)
|
|
79
|
+
msg = 'Auto-denied: permission mode is dont_ask'
|
|
80
|
+
emit_tool_result(request_id, tool_name, msg, success: false, args: args)
|
|
81
|
+
raise RubynCode::UserDeniedError, msg
|
|
82
|
+
when :accept_edits
|
|
83
|
+
if file_write?(tool_name)
|
|
84
|
+
execute_with_edit_gate(request_id, tool_name, args, &block)
|
|
85
|
+
else
|
|
86
|
+
execute_with_approval(request_id, tool_name, args, &block)
|
|
87
|
+
end
|
|
88
|
+
else # :default
|
|
89
|
+
if file_write?(tool_name)
|
|
90
|
+
execute_with_edit_gate(request_id, tool_name, args, &block)
|
|
91
|
+
else
|
|
92
|
+
execute_with_approval(request_id, tool_name, args, &block)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Called by ApproveToolUseHandler when the IDE client responds.
|
|
98
|
+
def resolve_approval(request_id, approved)
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
pending = @pending_approvals[request_id]
|
|
101
|
+
return false unless pending
|
|
102
|
+
|
|
103
|
+
pending[:approved] = approved
|
|
104
|
+
pending[:cv].signal
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Called by AcceptEditHandler when the IDE client responds.
|
|
110
|
+
def resolve_edit(edit_id, accepted)
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
pending = @pending_edits[edit_id]
|
|
113
|
+
return false unless pending
|
|
114
|
+
|
|
115
|
+
pending[:accepted] = accepted
|
|
116
|
+
pending[:cv].signal
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# ── Read-only and streaming paths ────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def execute_and_notify(request_id, tool_name, args)
|
|
126
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: false)
|
|
127
|
+
result = yield
|
|
128
|
+
emit_tool_result(request_id, tool_name, result, success: true, args: args)
|
|
129
|
+
result
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
|
|
132
|
+
raise
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ── File write tools (write_file, edit_file) ─────────────────────
|
|
136
|
+
|
|
137
|
+
def execute_with_edit_gate(request_id, tool_name, args, &)
|
|
138
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: false)
|
|
139
|
+
|
|
140
|
+
preview = compute_preview(tool_name, args)
|
|
141
|
+
return emit_error(request_id, tool_name, preview[:error]) if preview[:error]
|
|
142
|
+
|
|
143
|
+
# Always emit the file/edit or file/create notification so the
|
|
144
|
+
# extension can surface the change — opens a diff editor in normal
|
|
145
|
+
# mode or flashes "Rubyn auto-applied…" and applies via workspace
|
|
146
|
+
# edit in yolo mode. Either way the user sees what changed.
|
|
147
|
+
accepted = notify_and_await_edit(tool_name, preview, args)
|
|
148
|
+
return deny_edit(request_id, tool_name, preview[:type]) unless accepted
|
|
149
|
+
|
|
150
|
+
apply_edit(request_id, tool_name, args, &)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def compute_preview(tool_name, args)
|
|
154
|
+
tool = build_tool(tool_name)
|
|
155
|
+
sym_args = symbolize_keys(args)
|
|
156
|
+
result = tool.preview_content(**sym_args)
|
|
157
|
+
{ content: result[:content], type: result[:type] }
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
{ error: e.message }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_tool(tool_name)
|
|
163
|
+
klass = Tools::Registry.get(tool_name)
|
|
164
|
+
klass.new(project_root: @server.workspace_path || Dir.pwd)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def notify_and_await_edit(tool_name, preview, args)
|
|
168
|
+
edit_id = generate_id
|
|
169
|
+
path = args['path']
|
|
170
|
+
method = preview[:type] == 'create' ? 'file/create' : 'file/edit'
|
|
171
|
+
|
|
172
|
+
params = { 'editId' => edit_id, 'path' => path, 'content' => preview[:content] }
|
|
173
|
+
params['type'] = preview[:type] if method == 'file/edit'
|
|
174
|
+
|
|
175
|
+
@server.notify(method, params)
|
|
176
|
+
|
|
177
|
+
# In accept_edits mode, auto-approve file writes without waiting
|
|
178
|
+
return true if @permission_mode == :accept_edits
|
|
179
|
+
|
|
180
|
+
fire_permission_request(tool_name, edit_id)
|
|
181
|
+
wait_for_edit(edit_id)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def apply_edit(request_id, tool_name, args)
|
|
185
|
+
result = yield
|
|
186
|
+
emit_tool_result(request_id, tool_name, result, success: true, args: args)
|
|
187
|
+
result
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
|
|
190
|
+
raise
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def deny_edit(request_id, tool_name, type)
|
|
194
|
+
summary = "User rejected this #{type}. Do not retry the same content."
|
|
195
|
+
emit_tool_result(request_id, tool_name, summary, success: false)
|
|
196
|
+
raise RubynCode::UserDeniedError, summary
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def emit_error(request_id, tool_name, message)
|
|
200
|
+
summary = "Error: #{message}"
|
|
201
|
+
emit_tool_result(request_id, tool_name, summary, success: false)
|
|
202
|
+
summary
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# ── Approval-gated tools (bash, etc.) ────────────────────────────
|
|
206
|
+
|
|
207
|
+
def execute_with_approval(request_id, tool_name, args)
|
|
208
|
+
emit_tool_use(request_id, tool_name, args, requires_approval: true)
|
|
209
|
+
|
|
210
|
+
fire_permission_request(tool_name, request_id)
|
|
211
|
+
approved = wait_for_approval(request_id)
|
|
212
|
+
unless approved
|
|
213
|
+
summary = 'User refused this tool invocation. Do not retry the same call.'
|
|
214
|
+
emit_tool_result(request_id, tool_name, summary, success: false, args: args)
|
|
215
|
+
raise RubynCode::UserDeniedError, summary
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
result = yield
|
|
219
|
+
emit_tool_result(request_id, tool_name, result, success: true, args: args)
|
|
220
|
+
result
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
|
|
223
|
+
raise
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ── Notifications ────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def emit_tool_use(request_id, tool_name, args, requires_approval:)
|
|
229
|
+
@server.notify('tool/use', {
|
|
230
|
+
'requestId' => request_id,
|
|
231
|
+
'tool' => tool_name,
|
|
232
|
+
'args' => args,
|
|
233
|
+
'requiresApproval' => requires_approval
|
|
234
|
+
})
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def emit_tool_result(request_id, tool_name, result, success:, args: {})
|
|
238
|
+
@server.notify('tool/result', {
|
|
239
|
+
'requestId' => request_id,
|
|
240
|
+
'tool' => tool_name,
|
|
241
|
+
'success' => success,
|
|
242
|
+
'summary' => build_summary(tool_name, result, success, args)
|
|
243
|
+
})
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Ask the tool class for its one-line summary ("Edited foo.rb (1
|
|
247
|
+
# replacement)", "grep pattern (12 lines)", etc.). Tools that don't
|
|
248
|
+
# override Base.summarize return "", and the UI renders a clean
|
|
249
|
+
# "Done". The full tool output always lives in the conversation —
|
|
250
|
+
# summary is display-only. On failure we include the error so the
|
|
251
|
+
# user can see what went wrong.
|
|
252
|
+
def build_summary(tool_name, result, success, args)
|
|
253
|
+
return result.to_s[0, 500] unless success
|
|
254
|
+
|
|
255
|
+
klass = tool_class(tool_name)
|
|
256
|
+
return '' unless klass
|
|
257
|
+
|
|
258
|
+
klass.summarize(result.to_s, args || {}).to_s[0, 500]
|
|
259
|
+
rescue StandardError
|
|
260
|
+
''
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def tool_class(tool_name)
|
|
264
|
+
Tools::Registry.get(tool_name)
|
|
265
|
+
rescue ToolNotFoundError
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# ── Blocking waits ───────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
def wait_for_approval(request_id)
|
|
272
|
+
cv = ConditionVariable.new
|
|
273
|
+
@mutex.synchronize { @pending_approvals[request_id] = { cv: cv, approved: nil } }
|
|
274
|
+
|
|
275
|
+
@mutex.synchronize do
|
|
276
|
+
# Wait indefinitely — no timeout, no auto-deny. The user decides
|
|
277
|
+
# when they're ready. Poll periodically so thread interrupts
|
|
278
|
+
# (cancel) can break us out.
|
|
279
|
+
while @pending_approvals[request_id][:approved].nil?
|
|
280
|
+
cv.wait(@mutex, WAIT_POLL_INTERVAL)
|
|
281
|
+
end
|
|
282
|
+
@pending_approvals.delete(request_id)[:approved]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def wait_for_edit(edit_id)
|
|
287
|
+
cv = ConditionVariable.new
|
|
288
|
+
@mutex.synchronize { @pending_edits[edit_id] = { cv: cv, accepted: nil } }
|
|
289
|
+
|
|
290
|
+
@mutex.synchronize do
|
|
291
|
+
while @pending_edits[edit_id][:accepted].nil?
|
|
292
|
+
cv.wait(@mutex, WAIT_POLL_INTERVAL)
|
|
293
|
+
end
|
|
294
|
+
@pending_edits.delete(edit_id)[:accepted]
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# ── Hook helpers ───────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def fire_permission_request(tool_name, request_id)
|
|
301
|
+
@hook_runner&.fire(:permission_request, tool_name: tool_name, request_id: request_id)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# ── Helpers ──────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def read_only?(tool_name)
|
|
307
|
+
READ_ONLY_TOOLS.include?(tool_name)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def file_write?(tool_name)
|
|
311
|
+
FILE_WRITE_TOOLS.include?(tool_name)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def stringify_keys(hash)
|
|
315
|
+
return {} unless hash.is_a?(Hash)
|
|
316
|
+
|
|
317
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def symbolize_keys(hash)
|
|
321
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def generate_id
|
|
325
|
+
"#{Time.now.to_i}-#{SecureRandom.hex(4)}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module IDE
|
|
7
|
+
# Sends JSON-RPC requests from the CLI server to the VS Code extension
|
|
8
|
+
# and awaits responses. Enables the CLI to ask the IDE to do things like
|
|
9
|
+
# open diffs, read diagnostics, or navigate to a file.
|
|
10
|
+
#
|
|
11
|
+
# Uses the server's write mutex for thread-safe output. Tracks pending
|
|
12
|
+
# responses via a { id => ConditionVariable } map.
|
|
13
|
+
class Client
|
|
14
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
15
|
+
|
|
16
|
+
def initialize(server)
|
|
17
|
+
@server = server
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@next_id = 1000 # Start high to avoid collisions with client IDs
|
|
20
|
+
@pending = {} # { id => { cv: ConditionVariable, result: nil, error: nil } }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Send a JSON-RPC request to the extension and block until the response
|
|
24
|
+
# arrives or the timeout expires.
|
|
25
|
+
#
|
|
26
|
+
# @param method [String] the RPC method name (e.g. "ide/readSelection")
|
|
27
|
+
# @param params [Hash] the request params
|
|
28
|
+
# @param timeout [Numeric] seconds to wait for a response
|
|
29
|
+
# @return [Hash] the result from the extension
|
|
30
|
+
# @raise [TimeoutError] if no response within timeout
|
|
31
|
+
# @raise [StandardError] if the extension returns an RPC error
|
|
32
|
+
def request(method, params = {}, timeout: DEFAULT_TIMEOUT)
|
|
33
|
+
id = allocate_id
|
|
34
|
+
cv = ConditionVariable.new
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@pending[id] = { cv: cv, result: nil, error: nil }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Write the request via the server's write path
|
|
41
|
+
write_raw({
|
|
42
|
+
'jsonrpc' => Protocol::JSONRPC_VERSION,
|
|
43
|
+
'id' => id,
|
|
44
|
+
'method' => method,
|
|
45
|
+
'params' => Protocol.send(:stringify_keys_deep, params)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Block until the extension responds or we time out
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
deadline = Time.now + timeout
|
|
51
|
+
while @pending[id][:result].nil? && @pending[id][:error].nil?
|
|
52
|
+
remaining = deadline - Time.now
|
|
53
|
+
if remaining <= 0
|
|
54
|
+
@pending.delete(id)
|
|
55
|
+
raise TimeoutError, "IDE RPC request '#{method}' timed out after #{timeout}s"
|
|
56
|
+
end
|
|
57
|
+
cv.wait(@mutex, remaining)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
entry = @pending.delete(id)
|
|
61
|
+
raise StandardError, entry[:error] if entry[:error]
|
|
62
|
+
|
|
63
|
+
entry[:result]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Called by the server when it receives a response message (has id + result/error,
|
|
68
|
+
# no method) that matches one of our pending outbound requests.
|
|
69
|
+
#
|
|
70
|
+
# @param id [Integer] the response id
|
|
71
|
+
# @param result [Hash, nil] the result payload
|
|
72
|
+
# @param error [Hash, nil] the error payload
|
|
73
|
+
def resolve(id, result: nil, error: nil)
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
entry = @pending[id]
|
|
76
|
+
return unless entry
|
|
77
|
+
|
|
78
|
+
if error
|
|
79
|
+
entry[:error] = "RPC error #{error['code']}: #{error['message']}"
|
|
80
|
+
else
|
|
81
|
+
entry[:result] = result || {}
|
|
82
|
+
end
|
|
83
|
+
entry[:cv].signal
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if we have a pending request with this id.
|
|
88
|
+
def pending?(id)
|
|
89
|
+
@mutex.synchronize { @pending.key?(id) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def allocate_id
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
id = @next_id
|
|
97
|
+
@next_id += 1
|
|
98
|
+
id
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Write using the server's write method for thread-safe, testable output.
|
|
103
|
+
def write_raw(msg_hash)
|
|
104
|
+
@server.send(:write, msg_hash)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class TimeoutError < StandardError; end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module IDE
|
|
5
|
+
module Handlers
|
|
6
|
+
# Handles the "acceptEdit" JSON-RPC request.
|
|
7
|
+
#
|
|
8
|
+
# The extension sends this after the user accepts or rejects a proposed
|
|
9
|
+
# file edit surfaced via a file/edit or file/create notification. All
|
|
10
|
+
# pending-edit state lives in the per-session ToolOutput adapter; this
|
|
11
|
+
# handler is a thin delegate so the server has something to register at
|
|
12
|
+
# the method name.
|
|
13
|
+
class AcceptEditHandler
|
|
14
|
+
def initialize(server)
|
|
15
|
+
@server = server
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(params)
|
|
19
|
+
edit_id = params['editId']
|
|
20
|
+
accepted = params['accepted']
|
|
21
|
+
|
|
22
|
+
return { 'applied' => false, 'error' => 'Missing editId' } unless edit_id
|
|
23
|
+
|
|
24
|
+
adapter = @server.tool_output_adapter
|
|
25
|
+
return { 'applied' => false, 'error' => 'No active session' } unless adapter
|
|
26
|
+
|
|
27
|
+
resolved = adapter.resolve_edit(edit_id, accepted ? true : false)
|
|
28
|
+
return { 'applied' => false, 'error' => "No pending edit: #{edit_id}" } unless resolved
|
|
29
|
+
|
|
30
|
+
{ 'applied' => accepted ? true : false }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|