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,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,7 +10,7 @@ module RubynCode
|
|
|
10
10
|
class LoadError < StandardError; end
|
|
11
11
|
|
|
12
12
|
CONFIGURABLE_KEYS = %i[
|
|
13
|
-
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
|
|
@@ -19,7 +19,9 @@ module RubynCode
|
|
|
19
19
|
].freeze
|
|
20
20
|
|
|
21
21
|
DEFAULT_MAP = {
|
|
22
|
+
provider: Defaults::DEFAULT_PROVIDER,
|
|
22
23
|
model: Defaults::DEFAULT_MODEL,
|
|
24
|
+
model_mode: Defaults::MODEL_MODE,
|
|
23
25
|
max_iterations: Defaults::MAX_ITERATIONS,
|
|
24
26
|
max_sub_agent_iterations: Defaults::MAX_SUB_AGENT_ITERATIONS,
|
|
25
27
|
max_output_chars: Defaults::MAX_OUTPUT_CHARS,
|
|
@@ -42,7 +44,9 @@ module RubynCode
|
|
|
42
44
|
@config_path = config_path
|
|
43
45
|
@data = {}
|
|
44
46
|
ensure_home_directory!
|
|
47
|
+
seed_config! unless File.exist?(@config_path)
|
|
45
48
|
load!
|
|
49
|
+
backfill_provider_models!
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
# Define accessor methods for each configurable key
|
|
@@ -92,8 +96,106 @@ module RubynCode
|
|
|
92
96
|
def dangerous_patterns = Defaults::DANGEROUS_PATTERNS
|
|
93
97
|
def scrub_env_vars = Defaults::SCRUB_ENV_VARS
|
|
94
98
|
|
|
99
|
+
# Returns config hash for a custom provider, or nil if not configured.
|
|
100
|
+
# Reads from `providers.<name>` in config.yml.
|
|
101
|
+
#
|
|
102
|
+
# Expected keys: base_url, env_key, models, pricing
|
|
103
|
+
# pricing is a hash of model_name => [input_rate, output_rate]
|
|
104
|
+
def provider_config(name)
|
|
105
|
+
providers = @data.dig('providers', name.to_s)
|
|
106
|
+
return nil unless providers.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
providers.transform_keys(&:to_s)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Add or update a provider in the config and persist to disk.
|
|
112
|
+
#
|
|
113
|
+
# @param name [String] provider name (e.g., 'groq')
|
|
114
|
+
# @param base_url [String] API base URL
|
|
115
|
+
# @param env_key [String, nil] environment variable for the API key
|
|
116
|
+
# @param models [Array<String>] available model names
|
|
117
|
+
# @param pricing [Hash] model => [input_rate, output_rate]
|
|
118
|
+
# @param api_format [String, nil] API format ('openai' or 'anthropic')
|
|
119
|
+
def add_provider(name, base_url:, env_key: nil, models: [], pricing: {}, api_format: nil) # rubocop:disable Metrics/ParameterLists -- all optional kwargs with defaults
|
|
120
|
+
@data['providers'] ||= {}
|
|
121
|
+
@data['providers'][name.to_s] = build_provider_hash(
|
|
122
|
+
base_url: base_url, env_key: env_key, models: models, pricing: pricing, api_format: api_format
|
|
123
|
+
)
|
|
124
|
+
save!
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Returns all user-configured pricing as { model => [input, output] }
|
|
128
|
+
def custom_pricing
|
|
129
|
+
providers = @data['providers']
|
|
130
|
+
return {} unless providers.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
providers.each_with_object({}) do |(_, cfg), acc|
|
|
133
|
+
merge_provider_pricing(cfg, acc)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Default model tiers per built-in provider. Used by seed_config! and
|
|
138
|
+
# backfill_provider_models! so new and existing configs stay in sync.
|
|
139
|
+
DEFAULT_PROVIDER_MODELS = {
|
|
140
|
+
'anthropic' => {
|
|
141
|
+
'env_key' => 'ANTHROPIC_API_KEY',
|
|
142
|
+
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-6' }
|
|
143
|
+
},
|
|
144
|
+
'openai' => {
|
|
145
|
+
'env_key' => 'OPENAI_API_KEY',
|
|
146
|
+
'models' => { 'cheap' => 'gpt-5.4-nano', 'mid' => 'gpt-5.4-mini', 'top' => 'gpt-5.4' }
|
|
147
|
+
}
|
|
148
|
+
}.freeze
|
|
149
|
+
|
|
95
150
|
private
|
|
96
151
|
|
|
152
|
+
def seed_config!
|
|
153
|
+
@data = {
|
|
154
|
+
'provider' => Defaults::DEFAULT_PROVIDER,
|
|
155
|
+
'model' => Defaults::DEFAULT_MODEL,
|
|
156
|
+
'providers' => DEFAULT_PROVIDER_MODELS.transform_values(&:dup)
|
|
157
|
+
}
|
|
158
|
+
save!
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Backfills missing 'models' keys into existing provider configs.
|
|
162
|
+
# Never overwrites user-set values — only adds what's missing.
|
|
163
|
+
def backfill_provider_models! # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- iterates providers with guard clauses
|
|
164
|
+
providers = @data['providers']
|
|
165
|
+
return unless providers.is_a?(Hash)
|
|
166
|
+
|
|
167
|
+
changed = false
|
|
168
|
+
DEFAULT_PROVIDER_MODELS.each do |name, defaults|
|
|
169
|
+
next unless providers.key?(name)
|
|
170
|
+
next if providers[name].is_a?(Hash) && providers[name].key?('models')
|
|
171
|
+
|
|
172
|
+
providers[name] = {} unless providers[name].is_a?(Hash)
|
|
173
|
+
providers[name]['models'] = defaults['models'].dup
|
|
174
|
+
changed = true
|
|
175
|
+
end
|
|
176
|
+
save! if changed
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_provider_hash(base_url:, env_key:, models:, pricing:, api_format: nil)
|
|
182
|
+
hash = { 'base_url' => base_url }
|
|
183
|
+
hash['api_format'] = api_format if api_format
|
|
184
|
+
hash['env_key'] = env_key if env_key
|
|
185
|
+
hash['models'] = models unless models.empty?
|
|
186
|
+
hash['pricing'] = pricing unless pricing.empty?
|
|
187
|
+
hash
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def merge_provider_pricing(cfg, acc)
|
|
191
|
+
return unless cfg.is_a?(Hash) && cfg['pricing'].is_a?(Hash)
|
|
192
|
+
|
|
193
|
+
cfg['pricing'].each do |model, rates|
|
|
194
|
+
pair = Array(rates)
|
|
195
|
+
acc[model.to_s] = pair.map(&:to_f) if pair.size == 2
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
97
199
|
def ensure_home_directory!
|
|
98
200
|
dir = File.dirname(@config_path)
|
|
99
201
|
return if File.directory?(dir)
|
|
@@ -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
|
|
@@ -63,7 +63,7 @@ module RubynCode
|
|
|
63
63
|
]
|
|
64
64
|
|
|
65
65
|
options = {}
|
|
66
|
-
options[:model] = 'claude-sonnet-4-
|
|
66
|
+
options[:model] = 'claude-sonnet-4-6' if llm_client.respond_to?(:chat)
|
|
67
67
|
|
|
68
68
|
response = llm_client.chat(messages: summary_messages, **options)
|
|
69
69
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Context
|
|
5
|
+
# Budget-aware context loader that prioritizes which related files
|
|
6
|
+
# to load fully vs. as signatures-only. Prevents context bloat by
|
|
7
|
+
# capping auto-loaded context at a configurable token budget.
|
|
8
|
+
class ContextBudget
|
|
9
|
+
CHARS_PER_TOKEN = 4
|
|
10
|
+
DEFAULT_BUDGET = 4000 # tokens
|
|
11
|
+
|
|
12
|
+
# Rails convention-based priority for related files.
|
|
13
|
+
# Lower number = higher priority = loaded first.
|
|
14
|
+
PRIORITY_MAP = {
|
|
15
|
+
'spec' => 1, # tests for the file
|
|
16
|
+
'factory' => 2, # FactoryBot factories
|
|
17
|
+
'service' => 3, # service objects
|
|
18
|
+
'model' => 4, # related models
|
|
19
|
+
'controller' => 5, # controllers
|
|
20
|
+
'serializer' => 6, # serializers
|
|
21
|
+
'concern' => 7, # concerns/mixins
|
|
22
|
+
'helper' => 8, # helpers
|
|
23
|
+
'migration' => 9 # migrations
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :loaded_files, :signature_files, :tokens_used
|
|
27
|
+
|
|
28
|
+
def initialize(budget: DEFAULT_BUDGET, codebase_index: nil)
|
|
29
|
+
@budget = budget
|
|
30
|
+
@codebase_index = codebase_index
|
|
31
|
+
@loaded_files = []
|
|
32
|
+
@signature_files = []
|
|
33
|
+
@tokens_used = 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Load context for a primary file, filling budget with related files.
|
|
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.).
|
|
42
|
+
def load_for(file_path, related_files: [])
|
|
43
|
+
results = []
|
|
44
|
+
|
|
45
|
+
# Primary file always loads fully
|
|
46
|
+
primary_content = safe_read(file_path)
|
|
47
|
+
return results unless primary_content
|
|
48
|
+
|
|
49
|
+
primary_tokens = estimate_tokens(primary_content)
|
|
50
|
+
@tokens_used = primary_tokens
|
|
51
|
+
@loaded_files << file_path
|
|
52
|
+
results << { file: file_path, content: primary_content, mode: :full }
|
|
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
|
+
|
|
57
|
+
# Sort related files by priority and fill remaining budget
|
|
58
|
+
sorted = prioritize(related_files)
|
|
59
|
+
remaining = @budget - @tokens_used
|
|
60
|
+
remaining = load_full_files(sorted, results, remaining)
|
|
61
|
+
load_signature_files(sorted, results, remaining)
|
|
62
|
+
|
|
63
|
+
results
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Extract method signatures and class structure without method bodies.
|
|
67
|
+
# Much more compact than full source — typically 10-20% of original size.
|
|
68
|
+
def extract_signatures(content)
|
|
69
|
+
signatures = []
|
|
70
|
+
indent_stack = []
|
|
71
|
+
|
|
72
|
+
content.lines.each do |line|
|
|
73
|
+
process_signature_line(line, signatures, indent_stack)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
signatures.join
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns budget utilization stats.
|
|
80
|
+
def stats
|
|
81
|
+
{
|
|
82
|
+
budget: @budget,
|
|
83
|
+
tokens_used: @tokens_used,
|
|
84
|
+
utilization: @budget.positive? ? (@tokens_used.to_f / @budget).round(3) : 0.0,
|
|
85
|
+
full_files: @loaded_files.size,
|
|
86
|
+
signature_files: @signature_files.size
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
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
|
+
|
|
99
|
+
def load_full_files(sorted, results, remaining)
|
|
100
|
+
sorted.each do |rel_path|
|
|
101
|
+
content = safe_read(rel_path)
|
|
102
|
+
next unless content
|
|
103
|
+
|
|
104
|
+
size = estimate_tokens(content)
|
|
105
|
+
next unless size <= remaining
|
|
106
|
+
|
|
107
|
+
results << { file: rel_path, content: content, mode: :full }
|
|
108
|
+
@loaded_files << rel_path
|
|
109
|
+
@tokens_used += size
|
|
110
|
+
remaining -= size
|
|
111
|
+
end
|
|
112
|
+
remaining
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def load_signature_files(sorted, results, remaining)
|
|
116
|
+
sorted.each do |rel_path|
|
|
117
|
+
next if @loaded_files.include?(rel_path)
|
|
118
|
+
|
|
119
|
+
content = safe_read(rel_path)
|
|
120
|
+
next unless content
|
|
121
|
+
|
|
122
|
+
sigs = extract_signatures(content)
|
|
123
|
+
sig_size = estimate_tokens(sigs)
|
|
124
|
+
next unless sig_size <= remaining
|
|
125
|
+
|
|
126
|
+
results << { file: rel_path, content: sigs, mode: :signatures }
|
|
127
|
+
@signature_files << rel_path
|
|
128
|
+
@tokens_used += sig_size
|
|
129
|
+
remaining -= sig_size
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def process_signature_line(line, signatures, indent_stack) # rubocop:disable Metrics/AbcSize -- signature extraction dispatch
|
|
134
|
+
stripped = line.strip
|
|
135
|
+
if signature_line?(stripped)
|
|
136
|
+
signatures << line
|
|
137
|
+
indent_stack << current_indent(line) if block_opener?(stripped)
|
|
138
|
+
elsif stripped == 'end' && indent_stack.any? && current_indent(line) <= indent_stack.last
|
|
139
|
+
signatures << line
|
|
140
|
+
indent_stack.pop
|
|
141
|
+
elsif class_or_module_line?(stripped)
|
|
142
|
+
signatures << line
|
|
143
|
+
indent_stack << current_indent(line)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def prioritize(files)
|
|
148
|
+
files.sort_by do |path|
|
|
149
|
+
basename = File.basename(path, '.*').downcase
|
|
150
|
+
priority = PRIORITY_MAP.find { |key, _| basename.include?(key) }&.last || 10
|
|
151
|
+
priority
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def signature_line?(stripped)
|
|
156
|
+
stripped.match?(/\A\s*(def\s|attr_|include\s|extend\s|has_|belongs_|validates|scope\s|delegate\s)/)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def class_or_module_line?(stripped)
|
|
160
|
+
stripped.match?(/\A\s*(class|module)\s/)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def block_opener?(stripped)
|
|
164
|
+
stripped.match?(/\Adef\s/)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def current_indent(line)
|
|
168
|
+
line.match(/\A(\s*)/)[1].length
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def safe_read(path)
|
|
172
|
+
File.read(path)
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def estimate_tokens(text)
|
|
178
|
+
(text.bytesize.to_f / CHARS_PER_TOKEN).ceil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Context
|
|
5
|
+
# Triggers compaction at logical decision boundaries rather than
|
|
6
|
+
# only at capacity limits. This prevents late-session context bloat
|
|
7
|
+
# by compacting after meaningful milestones.
|
|
8
|
+
class DecisionCompactor
|
|
9
|
+
# Percentage of context threshold at which to trigger compaction
|
|
10
|
+
# on decision boundaries (lower than the default 95%).
|
|
11
|
+
EARLY_COMPACT_RATIO = 0.6
|
|
12
|
+
|
|
13
|
+
TRIGGERS = %i[
|
|
14
|
+
specs_passed
|
|
15
|
+
topic_switch
|
|
16
|
+
multi_file_edit_complete
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :pending_trigger
|
|
20
|
+
|
|
21
|
+
def initialize(context_manager:, threshold: nil)
|
|
22
|
+
@context_manager = context_manager
|
|
23
|
+
@threshold = threshold || Config::Defaults::CONTEXT_THRESHOLD_TOKENS
|
|
24
|
+
@pending_trigger = nil
|
|
25
|
+
@last_topic_keywords = Set.new
|
|
26
|
+
@edited_files = Set.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Signal that specs passed after implementation.
|
|
30
|
+
def signal_specs_passed!
|
|
31
|
+
@pending_trigger = :specs_passed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Signal that a file was edited (for multi-file tracking).
|
|
35
|
+
def signal_file_edited!(path)
|
|
36
|
+
@edited_files << path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Signal that multi-file editing is complete.
|
|
40
|
+
def signal_edit_batch_complete!
|
|
41
|
+
return unless @edited_files.size > 1
|
|
42
|
+
|
|
43
|
+
@pending_trigger = :multi_file_edit_complete
|
|
44
|
+
@edited_files.clear
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Detect topic switch from user message keywords.
|
|
48
|
+
def detect_topic_switch(user_message)
|
|
49
|
+
keywords = extract_keywords(user_message)
|
|
50
|
+
overlap = keywords & @last_topic_keywords
|
|
51
|
+
|
|
52
|
+
@pending_trigger = :topic_switch if @last_topic_keywords.any? && overlap.empty? && keywords.any?
|
|
53
|
+
|
|
54
|
+
@last_topic_keywords = keywords
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if compaction should run based on decision boundaries.
|
|
58
|
+
# Returns true if compaction was triggered.
|
|
59
|
+
def check!(conversation) # rubocop:disable Naming/PredicateMethod -- side-effectful: triggers compaction, not just a query
|
|
60
|
+
return false unless should_compact?(conversation)
|
|
61
|
+
|
|
62
|
+
trigger = @pending_trigger
|
|
63
|
+
@pending_trigger = nil
|
|
64
|
+
RubynCode::Debug.token("Decision compaction triggered: #{trigger}")
|
|
65
|
+
@context_manager.check_compaction!(conversation)
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reset all tracked state.
|
|
70
|
+
def reset!
|
|
71
|
+
@pending_trigger = nil
|
|
72
|
+
@last_topic_keywords.clear
|
|
73
|
+
@edited_files.clear
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
STOPWORDS = %w[the and for this that with from have been will your what].to_set.freeze
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def should_compact?(conversation)
|
|
81
|
+
return false unless @pending_trigger
|
|
82
|
+
|
|
83
|
+
est = @context_manager.estimated_tokens(conversation.messages)
|
|
84
|
+
est > (@threshold * EARLY_COMPACT_RATIO)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def extract_keywords(text)
|
|
88
|
+
text.to_s.downcase
|
|
89
|
+
.scan(/\b[a-z]{3,}\b/)
|
|
90
|
+
.reject { |w| stopword?(w) }
|
|
91
|
+
.to_set
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stopword?(word)
|
|
95
|
+
STOPWORDS.include?(word)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -10,13 +10,25 @@ 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
|
|
17
|
+
def initialize(threshold: Config::Defaults::CONTEXT_THRESHOLD_TOKENS, llm_client: nil)
|
|
17
18
|
@threshold = threshold
|
|
19
|
+
@llm_client = llm_client
|
|
18
20
|
@total_input_tokens = 0
|
|
19
21
|
@total_output_tokens = 0
|
|
22
|
+
@last_compaction_turn = -1
|
|
23
|
+
@current_turn = 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_writer :llm_client
|
|
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
|
|
20
32
|
end
|
|
21
33
|
|
|
22
34
|
# Accumulates token counts from an LLM response usage object.
|
|
@@ -56,16 +68,24 @@ module RubynCode
|
|
|
56
68
|
# Fraction of the compaction threshold at which micro-compact kicks in.
|
|
57
69
|
# Running it too early busts the prompt cache prefix (mutated messages
|
|
58
70
|
# change the hash, invalidating server-side cached tokens).
|
|
59
|
-
|
|
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
|
|
60
75
|
|
|
61
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
|
+
|
|
62
82
|
messages = conversation.messages
|
|
63
83
|
|
|
64
84
|
# Step 1: Zero-cost micro-compact — but only when we're approaching
|
|
65
85
|
# the compaction threshold. Running it every turn mutates old messages,
|
|
66
86
|
# which invalidates the prompt cache prefix and wastes tokens.
|
|
67
87
|
est = estimated_tokens(messages)
|
|
68
|
-
MicroCompact.call(messages) if est > (@threshold *
|
|
88
|
+
MicroCompact.call(messages) if est > (@threshold * micro_compact_ratio)
|
|
69
89
|
|
|
70
90
|
return unless needs_compaction?(messages)
|
|
71
91
|
|
|
@@ -77,11 +97,9 @@ module RubynCode
|
|
|
77
97
|
end
|
|
78
98
|
|
|
79
99
|
# Step 3: Full LLM-driven auto-compact (expensive, last resort)
|
|
80
|
-
|
|
81
|
-
llm_client: conversation.respond_to?(:llm_client) ? conversation.llm_client : nil,
|
|
82
|
-
threshold: @threshold
|
|
83
|
-
)
|
|
100
|
+
return unless @llm_client
|
|
84
101
|
|
|
102
|
+
compactor = Compactor.new(llm_client: @llm_client, threshold: @threshold)
|
|
85
103
|
new_messages = compactor.auto_compact!(messages)
|
|
86
104
|
apply_compacted_messages(conversation, new_messages)
|
|
87
105
|
end
|
|
@@ -92,10 +110,28 @@ module RubynCode
|
|
|
92
110
|
def reset!
|
|
93
111
|
@total_input_tokens = 0
|
|
94
112
|
@total_output_tokens = 0
|
|
113
|
+
@last_compaction_turn = -1
|
|
114
|
+
@current_turn = 0
|
|
95
115
|
end
|
|
96
116
|
|
|
97
117
|
private
|
|
98
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
|
+
|
|
99
135
|
def apply_compacted_messages(conversation, new_messages)
|
|
100
136
|
if conversation.respond_to?(:replace_messages)
|
|
101
137
|
conversation.replace_messages(new_messages)
|