rubyn-code 0.3.0 → 0.5.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 +263 -21
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +34 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
- data/lib/rubyn_code/agent/llm_caller.rb +11 -1
- data/lib/rubyn_code/agent/loop.rb +14 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
- data/lib/rubyn_code/agent/tool_processor.rb +25 -3
- 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 +116 -11
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
- data/lib/rubyn_code/cli/commands/list_skills.rb +149 -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 +124 -0
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +54 -3
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- 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 +15 -0
- data/lib/rubyn_code/cli/repl_commands.rb +3 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +74 -1
- data/lib/rubyn_code/config/defaults.rb +3 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +12 -6
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +18 -2
- 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 +218 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -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 +112 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +69 -2
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- 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/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
- data/lib/rubyn_code/output/diff_renderer.rb +3 -2
- data/lib/rubyn_code/self_test.rb +316 -0
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +76 -0
- data/lib/rubyn_code/skills/document.rb +8 -2
- data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/matcher.rb +89 -0
- data/lib/rubyn_code/skills/pack_context.rb +163 -0
- data/lib/rubyn_code/skills/pack_installer.rb +194 -0
- data/lib/rubyn_code/skills/pack_manager.rb +230 -0
- data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
- data/lib/rubyn_code/skills/registry_client.rb +241 -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 +65 -8
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +7 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +9 -7
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +34 -0
- data/skills/rubyn_self_test.md +88 -1
- metadata +43 -1
|
@@ -11,6 +11,7 @@ module RubynCode
|
|
|
11
11
|
setup_services!
|
|
12
12
|
setup_executor_callbacks!
|
|
13
13
|
setup_hooks!
|
|
14
|
+
setup_mcp_servers!
|
|
14
15
|
setup_agent_loop!
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -41,9 +42,41 @@ module RubynCode
|
|
|
41
42
|
@budget_enforcer = Observability::BudgetEnforcer.new(@db, session_id: current_session_id)
|
|
42
43
|
@background_worker = Background::Worker.new(project_root: @project_root)
|
|
43
44
|
@skill_loader = Skills::Loader.new(Skills::Catalog.new(skill_dirs))
|
|
45
|
+
@skill_matcher = build_skill_matcher
|
|
46
|
+
@web_skill_autoload = build_web_skill_autoload
|
|
44
47
|
@session_persistence = Memory::SessionPersistence.new(@db)
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
def build_skill_matcher
|
|
51
|
+
return nil unless skills_autoload_enabled?
|
|
52
|
+
|
|
53
|
+
Skills::Matcher.new(catalog: @skill_loader.catalog, project_root: @project_root)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_web_skill_autoload
|
|
57
|
+
return nil unless @skill_matcher && skills_autoload_enabled?
|
|
58
|
+
|
|
59
|
+
Skills::RegistryAutoload.new(
|
|
60
|
+
loader: @skill_loader,
|
|
61
|
+
matcher: @skill_matcher,
|
|
62
|
+
on_fetching: on_pack_fetching_callback
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def skills_autoload_enabled?
|
|
67
|
+
Config::Settings.new.skills_autoload
|
|
68
|
+
rescue Config::Settings::LoadError
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def on_skills_autoloaded_callback
|
|
73
|
+
->(names) { @renderer.system_message("📚 Loaded: #{names.join(' · ')}") }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def on_pack_fetching_callback
|
|
77
|
+
->(name) { @renderer.system_message("📥 Fetching skill pack '#{name}' from registry…") }
|
|
78
|
+
end
|
|
79
|
+
|
|
47
80
|
def setup_executor_callbacks!
|
|
48
81
|
@tool_executor.llm_client = @llm_client
|
|
49
82
|
@tool_executor.background_worker = @background_worker
|
|
@@ -106,7 +139,10 @@ module RubynCode
|
|
|
106
139
|
on_tool_call: ->(name, params) { handle_on_tool_call(name, params) },
|
|
107
140
|
on_tool_result: ->(name, result, _is_error = false) { handle_on_tool_result(name, result) },
|
|
108
141
|
on_text: ->(text) { handle_on_text(text) },
|
|
109
|
-
|
|
142
|
+
on_skills_autoloaded: on_skills_autoloaded_callback,
|
|
143
|
+
skill_loader: @skill_loader, skill_matcher: @skill_matcher,
|
|
144
|
+
web_skill_autoload: @web_skill_autoload,
|
|
145
|
+
project_root: @project_root
|
|
110
146
|
)
|
|
111
147
|
end
|
|
112
148
|
|
|
@@ -138,8 +174,45 @@ module RubynCode
|
|
|
138
174
|
dirs << project_skills if Dir.exist?(project_skills)
|
|
139
175
|
user_skills = File.join(Config::Defaults::HOME_DIR, 'skills')
|
|
140
176
|
dirs << user_skills if Dir.exist?(user_skills)
|
|
177
|
+
skill_packs = File.join(Config::Defaults::HOME_DIR, 'skill-packs')
|
|
178
|
+
dirs << skill_packs if Dir.exist?(skill_packs)
|
|
141
179
|
dirs
|
|
142
180
|
end
|
|
181
|
+
|
|
182
|
+
# ── MCP Server Wiring ─────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def setup_mcp_servers!
|
|
185
|
+
@mcp_clients = []
|
|
186
|
+
server_configs = MCP::Config.load(@project_root)
|
|
187
|
+
return if server_configs.empty?
|
|
188
|
+
|
|
189
|
+
server_configs.each do |config|
|
|
190
|
+
connect_mcp_server(config)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
at_exit { disconnect_mcp_clients! unless defined?(RSpec) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def connect_mcp_server(config)
|
|
197
|
+
client = MCP::Client.from_config(config)
|
|
198
|
+
client.connect!
|
|
199
|
+
MCP::ToolBridge.bridge(client)
|
|
200
|
+
@mcp_clients << client
|
|
201
|
+
@renderer.info("MCP server '#{config[:name]}' connected (#{client.tools.size} tools)")
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
warn "[MCP] Failed to connect '#{config[:name]}': #{e.message}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def disconnect_mcp_clients!
|
|
207
|
+
return if @mcp_clients.nil? || @mcp_clients.empty?
|
|
208
|
+
|
|
209
|
+
@mcp_clients.each do |client|
|
|
210
|
+
client.disconnect!
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
warn "[MCP] Error disconnecting '#{client.name}': #{e.message}"
|
|
213
|
+
end
|
|
214
|
+
@mcp_clients.clear
|
|
215
|
+
end
|
|
143
216
|
end
|
|
144
217
|
end
|
|
145
218
|
end
|
|
@@ -12,6 +12,7 @@ module RubynCode
|
|
|
12
12
|
|
|
13
13
|
DEFAULT_PROVIDER = 'anthropic'
|
|
14
14
|
DEFAULT_MODEL = 'claude-opus-4-6'
|
|
15
|
+
MODEL_MODE = 'auto' # 'auto' or 'manual'
|
|
15
16
|
MAX_ITERATIONS = 200
|
|
16
17
|
MAX_SUB_AGENT_ITERATIONS = 200
|
|
17
18
|
MAX_EXPLORE_AGENT_ITERATIONS = 200
|
|
@@ -30,6 +31,8 @@ module RubynCode
|
|
|
30
31
|
POLL_INTERVAL = 5
|
|
31
32
|
IDLE_TIMEOUT = 60
|
|
32
33
|
|
|
34
|
+
SKILLS_AUTOLOAD = true
|
|
35
|
+
|
|
33
36
|
SESSION_BUDGET_USD = 5.00
|
|
34
37
|
DAILY_BUDGET_USD = 10.00
|
|
35
38
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"provider": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"minLength": 1
|
|
8
|
+
},
|
|
9
|
+
"model": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"minLength": 1
|
|
12
|
+
},
|
|
13
|
+
"model_mode": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"enum": ["auto", "manual"]
|
|
16
|
+
},
|
|
17
|
+
"max_iterations": {
|
|
18
|
+
"type": "integer",
|
|
19
|
+
"minimum": 1,
|
|
20
|
+
"maximum": 1000
|
|
21
|
+
},
|
|
22
|
+
"max_sub_agent_iterations": {
|
|
23
|
+
"type": "integer",
|
|
24
|
+
"minimum": 1,
|
|
25
|
+
"maximum": 500
|
|
26
|
+
},
|
|
27
|
+
"max_output_chars": {
|
|
28
|
+
"type": "integer",
|
|
29
|
+
"minimum": 1000,
|
|
30
|
+
"maximum": 1000000
|
|
31
|
+
},
|
|
32
|
+
"context_threshold_tokens": {
|
|
33
|
+
"type": "integer",
|
|
34
|
+
"minimum": 10000,
|
|
35
|
+
"maximum": 200000
|
|
36
|
+
},
|
|
37
|
+
"session_budget_usd": {
|
|
38
|
+
"type": "number",
|
|
39
|
+
"minimum": 0.1,
|
|
40
|
+
"maximum": 100
|
|
41
|
+
},
|
|
42
|
+
"daily_budget_usd": {
|
|
43
|
+
"type": "number",
|
|
44
|
+
"minimum": 0.5,
|
|
45
|
+
"maximum": 500
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"additionalProperties": true
|
|
49
|
+
}
|
|
@@ -10,17 +10,19 @@ module RubynCode
|
|
|
10
10
|
class LoadError < StandardError; end
|
|
11
11
|
|
|
12
12
|
CONFIGURABLE_KEYS = %i[
|
|
13
|
-
provider model max_iterations max_sub_agent_iterations max_output_chars
|
|
13
|
+
provider model model_mode max_iterations max_sub_agent_iterations max_output_chars
|
|
14
14
|
context_threshold_tokens micro_compact_keep_recent
|
|
15
15
|
poll_interval idle_timeout
|
|
16
16
|
session_budget_usd daily_budget_usd
|
|
17
17
|
oauth_client_id oauth_redirect_uri oauth_authorize_url
|
|
18
18
|
oauth_token_url oauth_scopes
|
|
19
|
+
skills_autoload
|
|
19
20
|
].freeze
|
|
20
21
|
|
|
21
22
|
DEFAULT_MAP = {
|
|
22
23
|
provider: Defaults::DEFAULT_PROVIDER,
|
|
23
24
|
model: Defaults::DEFAULT_MODEL,
|
|
25
|
+
model_mode: Defaults::MODEL_MODE,
|
|
24
26
|
max_iterations: Defaults::MAX_ITERATIONS,
|
|
25
27
|
max_sub_agent_iterations: Defaults::MAX_SUB_AGENT_ITERATIONS,
|
|
26
28
|
max_output_chars: Defaults::MAX_OUTPUT_CHARS,
|
|
@@ -34,7 +36,8 @@ module RubynCode
|
|
|
34
36
|
oauth_redirect_uri: Defaults::OAUTH_REDIRECT_URI,
|
|
35
37
|
oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
|
|
36
38
|
oauth_token_url: Defaults::OAUTH_TOKEN_URL,
|
|
37
|
-
oauth_scopes: Defaults::OAUTH_SCOPES
|
|
39
|
+
oauth_scopes: Defaults::OAUTH_SCOPES,
|
|
40
|
+
skills_autoload: Defaults::SKILLS_AUTOLOAD
|
|
38
41
|
}.freeze
|
|
39
42
|
|
|
40
43
|
attr_reader :config_path, :data
|
|
@@ -114,10 +117,11 @@ module RubynCode
|
|
|
114
117
|
# @param env_key [String, nil] environment variable for the API key
|
|
115
118
|
# @param models [Array<String>] available model names
|
|
116
119
|
# @param pricing [Hash] model => [input_rate, output_rate]
|
|
117
|
-
|
|
120
|
+
# @param api_format [String, nil] API format ('openai' or 'anthropic')
|
|
121
|
+
def add_provider(name, base_url:, env_key: nil, models: [], pricing: {}, api_format: nil) # rubocop:disable Metrics/ParameterLists -- all optional kwargs with defaults
|
|
118
122
|
@data['providers'] ||= {}
|
|
119
123
|
@data['providers'][name.to_s] = build_provider_hash(
|
|
120
|
-
base_url: base_url, env_key: env_key, models: models, pricing: pricing
|
|
124
|
+
base_url: base_url, env_key: env_key, models: models, pricing: pricing, api_format: api_format
|
|
121
125
|
)
|
|
122
126
|
save!
|
|
123
127
|
end
|
|
@@ -158,7 +162,8 @@ module RubynCode
|
|
|
158
162
|
|
|
159
163
|
# Backfills missing 'models' keys into existing provider configs.
|
|
160
164
|
# Never overwrites user-set values — only adds what's missing.
|
|
161
|
-
|
|
165
|
+
# -- iterates providers with guard clauses
|
|
166
|
+
def backfill_provider_models!
|
|
162
167
|
providers = @data['providers']
|
|
163
168
|
return unless providers.is_a?(Hash)
|
|
164
169
|
|
|
@@ -176,8 +181,9 @@ module RubynCode
|
|
|
176
181
|
nil
|
|
177
182
|
end
|
|
178
183
|
|
|
179
|
-
def build_provider_hash(base_url:, env_key:, models:, pricing:)
|
|
184
|
+
def build_provider_hash(base_url:, env_key:, models:, pricing:, api_format: nil)
|
|
180
185
|
hash = { 'base_url' => base_url }
|
|
186
|
+
hash['api_format'] = api_format if api_format
|
|
181
187
|
hash['env_key'] = env_key if env_key
|
|
182
188
|
hash['models'] = models unless models.empty?
|
|
183
189
|
hash['pricing'] = pricing unless pricing.empty?
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'json_schemer'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Config
|
|
8
|
+
class Validator
|
|
9
|
+
SCHEMA_PATH = File.expand_path('schema.json', __dir__)
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@raw_schema = JSON.parse(File.read(SCHEMA_PATH))
|
|
13
|
+
@schemer = JSONSchemer.schema(@raw_schema)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Validates a single config key/value pair against the schema.
|
|
17
|
+
#
|
|
18
|
+
# @param key [String] the config key
|
|
19
|
+
# @param value [Object] the value to validate
|
|
20
|
+
# @return [Hash] { valid: true/false, errors: [String] }
|
|
21
|
+
def validate(key, value)
|
|
22
|
+
# If the key has no schema definition, accept any value
|
|
23
|
+
properties = @raw_schema.fetch('properties', {})
|
|
24
|
+
unless properties.key?(key.to_s)
|
|
25
|
+
return { valid: true, errors: [] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
doc = { key.to_s => value }
|
|
29
|
+
errors = @schemer.validate(doc).select { |e| e['data_pointer'] == "/#{key}" }
|
|
30
|
+
|
|
31
|
+
if errors.empty?
|
|
32
|
+
{ valid: true, errors: [] }
|
|
33
|
+
else
|
|
34
|
+
messages = errors.map { |e| format_error(key, e) }
|
|
35
|
+
{ valid: false, errors: messages }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def format_error(key, error)
|
|
42
|
+
detail = error['type']
|
|
43
|
+
schema_node = error.fetch('schema', {})
|
|
44
|
+
|
|
45
|
+
parts = ["#{key}: invalid value"]
|
|
46
|
+
parts << "(expected #{detail})" if detail
|
|
47
|
+
|
|
48
|
+
if schema_node.key?('minimum') || schema_node.key?('maximum')
|
|
49
|
+
range_parts = []
|
|
50
|
+
range_parts << "min #{schema_node['minimum']}" if schema_node.key?('minimum')
|
|
51
|
+
range_parts << "max #{schema_node['maximum']}" if schema_node.key?('maximum')
|
|
52
|
+
parts << "[#{range_parts.join(', ')}]"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if schema_node.key?('enum')
|
|
56
|
+
parts << "allowed: #{schema_node['enum'].join(', ')}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
parts.join(' ')
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -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)
|
|
@@ -115,7 +130,8 @@ module RubynCode
|
|
|
115
130
|
end
|
|
116
131
|
end
|
|
117
132
|
|
|
118
|
-
|
|
133
|
+
# -- signature extraction dispatch
|
|
134
|
+
def process_signature_line(line, signatures, indent_stack)
|
|
119
135
|
stripped = line.strip
|
|
120
136
|
if signature_line?(stripped)
|
|
121
137
|
signatures << line
|
|
@@ -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,22 @@ 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 succeeded this turn
|
|
78
|
+
return if @last_compaction_turn == @current_turn
|
|
79
|
+
|
|
66
80
|
messages = conversation.messages
|
|
67
81
|
|
|
68
82
|
# Step 1: Zero-cost micro-compact — but only when we're approaching
|
|
69
83
|
# the compaction threshold. Running it every turn mutates old messages,
|
|
70
84
|
# which invalidates the prompt cache prefix and wastes tokens.
|
|
71
85
|
est = estimated_tokens(messages)
|
|
72
|
-
MicroCompact.call(messages) if est > (@threshold *
|
|
86
|
+
MicroCompact.call(messages) if est > (@threshold * micro_compact_ratio)
|
|
73
87
|
|
|
74
88
|
return unless needs_compaction?(messages)
|
|
75
89
|
|
|
@@ -77,6 +91,7 @@ module RubynCode
|
|
|
77
91
|
collapsed = ContextCollapse.call(messages, threshold: @threshold)
|
|
78
92
|
if collapsed
|
|
79
93
|
apply_compacted_messages(conversation, collapsed)
|
|
94
|
+
@last_compaction_turn = @current_turn
|
|
80
95
|
return
|
|
81
96
|
end
|
|
82
97
|
|
|
@@ -86,6 +101,7 @@ module RubynCode
|
|
|
86
101
|
compactor = Compactor.new(llm_client: @llm_client, threshold: @threshold)
|
|
87
102
|
new_messages = compactor.auto_compact!(messages)
|
|
88
103
|
apply_compacted_messages(conversation, new_messages)
|
|
104
|
+
@last_compaction_turn = @current_turn
|
|
89
105
|
end
|
|
90
106
|
|
|
91
107
|
# Resets cumulative token counters to zero.
|
|
@@ -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
|
|