rubyn-code 0.2.2 → 0.3.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 +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -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 +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -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 +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -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 +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- 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/index/codebase_index.rb +245 -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 +270 -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 +46 -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 +55 -252
- 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 +9 -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/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/skills/document.rb +33 -29
- 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/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- 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 +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- 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 +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- 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 +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- metadata +53 -1
|
@@ -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 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,6 +19,7 @@ module RubynCode
|
|
|
19
19
|
].freeze
|
|
20
20
|
|
|
21
21
|
DEFAULT_MAP = {
|
|
22
|
+
provider: Defaults::DEFAULT_PROVIDER,
|
|
22
23
|
model: Defaults::DEFAULT_MODEL,
|
|
23
24
|
max_iterations: Defaults::MAX_ITERATIONS,
|
|
24
25
|
max_sub_agent_iterations: Defaults::MAX_SUB_AGENT_ITERATIONS,
|
|
@@ -42,7 +43,9 @@ module RubynCode
|
|
|
42
43
|
@config_path = config_path
|
|
43
44
|
@data = {}
|
|
44
45
|
ensure_home_directory!
|
|
46
|
+
seed_config! unless File.exist?(@config_path)
|
|
45
47
|
load!
|
|
48
|
+
backfill_provider_models!
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
# Define accessor methods for each configurable key
|
|
@@ -92,8 +95,104 @@ module RubynCode
|
|
|
92
95
|
def dangerous_patterns = Defaults::DANGEROUS_PATTERNS
|
|
93
96
|
def scrub_env_vars = Defaults::SCRUB_ENV_VARS
|
|
94
97
|
|
|
98
|
+
# Returns config hash for a custom provider, or nil if not configured.
|
|
99
|
+
# Reads from `providers.<name>` in config.yml.
|
|
100
|
+
#
|
|
101
|
+
# Expected keys: base_url, env_key, models, pricing
|
|
102
|
+
# pricing is a hash of model_name => [input_rate, output_rate]
|
|
103
|
+
def provider_config(name)
|
|
104
|
+
providers = @data.dig('providers', name.to_s)
|
|
105
|
+
return nil unless providers.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
providers.transform_keys(&:to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Add or update a provider in the config and persist to disk.
|
|
111
|
+
#
|
|
112
|
+
# @param name [String] provider name (e.g., 'groq')
|
|
113
|
+
# @param base_url [String] API base URL
|
|
114
|
+
# @param env_key [String, nil] environment variable for the API key
|
|
115
|
+
# @param models [Array<String>] available model names
|
|
116
|
+
# @param pricing [Hash] model => [input_rate, output_rate]
|
|
117
|
+
def add_provider(name, base_url:, env_key: nil, models: [], pricing: {})
|
|
118
|
+
@data['providers'] ||= {}
|
|
119
|
+
@data['providers'][name.to_s] = build_provider_hash(
|
|
120
|
+
base_url: base_url, env_key: env_key, models: models, pricing: pricing
|
|
121
|
+
)
|
|
122
|
+
save!
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Returns all user-configured pricing as { model => [input, output] }
|
|
126
|
+
def custom_pricing
|
|
127
|
+
providers = @data['providers']
|
|
128
|
+
return {} unless providers.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
providers.each_with_object({}) do |(_, cfg), acc|
|
|
131
|
+
merge_provider_pricing(cfg, acc)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Default model tiers per built-in provider. Used by seed_config! and
|
|
136
|
+
# backfill_provider_models! so new and existing configs stay in sync.
|
|
137
|
+
DEFAULT_PROVIDER_MODELS = {
|
|
138
|
+
'anthropic' => {
|
|
139
|
+
'env_key' => 'ANTHROPIC_API_KEY',
|
|
140
|
+
'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-6' }
|
|
141
|
+
},
|
|
142
|
+
'openai' => {
|
|
143
|
+
'env_key' => 'OPENAI_API_KEY',
|
|
144
|
+
'models' => { 'cheap' => 'gpt-5.4-nano', 'mid' => 'gpt-5.4-mini', 'top' => 'gpt-5.4' }
|
|
145
|
+
}
|
|
146
|
+
}.freeze
|
|
147
|
+
|
|
95
148
|
private
|
|
96
149
|
|
|
150
|
+
def seed_config!
|
|
151
|
+
@data = {
|
|
152
|
+
'provider' => Defaults::DEFAULT_PROVIDER,
|
|
153
|
+
'model' => Defaults::DEFAULT_MODEL,
|
|
154
|
+
'providers' => DEFAULT_PROVIDER_MODELS.transform_values(&:dup)
|
|
155
|
+
}
|
|
156
|
+
save!
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Backfills missing 'models' keys into existing provider configs.
|
|
160
|
+
# Never overwrites user-set values — only adds what's missing.
|
|
161
|
+
def backfill_provider_models! # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- iterates providers with guard clauses
|
|
162
|
+
providers = @data['providers']
|
|
163
|
+
return unless providers.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
changed = false
|
|
166
|
+
DEFAULT_PROVIDER_MODELS.each do |name, defaults|
|
|
167
|
+
next unless providers.key?(name)
|
|
168
|
+
next if providers[name].is_a?(Hash) && providers[name].key?('models')
|
|
169
|
+
|
|
170
|
+
providers[name] = {} unless providers[name].is_a?(Hash)
|
|
171
|
+
providers[name]['models'] = defaults['models'].dup
|
|
172
|
+
changed = true
|
|
173
|
+
end
|
|
174
|
+
save! if changed
|
|
175
|
+
rescue StandardError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_provider_hash(base_url:, env_key:, models:, pricing:)
|
|
180
|
+
hash = { 'base_url' => base_url }
|
|
181
|
+
hash['env_key'] = env_key if env_key
|
|
182
|
+
hash['models'] = models unless models.empty?
|
|
183
|
+
hash['pricing'] = pricing unless pricing.empty?
|
|
184
|
+
hash
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def merge_provider_pricing(cfg, acc)
|
|
188
|
+
return unless cfg.is_a?(Hash) && cfg['pricing'].is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
cfg['pricing'].each do |model, rates|
|
|
191
|
+
pair = Array(rates)
|
|
192
|
+
acc[model.to_s] = pair.map(&:to_f) if pair.size == 2
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
97
196
|
def ensure_home_directory!
|
|
98
197
|
dir = File.dirname(@config_path)
|
|
99
198
|
return if File.directory?(dir)
|
|
@@ -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,167 @@
|
|
|
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)
|
|
29
|
+
@budget = budget
|
|
30
|
+
@loaded_files = []
|
|
31
|
+
@signature_files = []
|
|
32
|
+
@tokens_used = 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Load context for a primary file, filling budget with related files.
|
|
36
|
+
# Returns array of { file:, content:, mode: :full|:signatures }
|
|
37
|
+
def load_for(file_path, related_files: [])
|
|
38
|
+
results = []
|
|
39
|
+
|
|
40
|
+
# Primary file always loads fully
|
|
41
|
+
primary_content = safe_read(file_path)
|
|
42
|
+
return results unless primary_content
|
|
43
|
+
|
|
44
|
+
primary_tokens = estimate_tokens(primary_content)
|
|
45
|
+
@tokens_used = primary_tokens
|
|
46
|
+
@loaded_files << file_path
|
|
47
|
+
results << { file: file_path, content: primary_content, mode: :full }
|
|
48
|
+
|
|
49
|
+
# Sort related files by priority and fill remaining budget
|
|
50
|
+
sorted = prioritize(related_files)
|
|
51
|
+
remaining = @budget - @tokens_used
|
|
52
|
+
remaining = load_full_files(sorted, results, remaining)
|
|
53
|
+
load_signature_files(sorted, results, remaining)
|
|
54
|
+
|
|
55
|
+
results
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Extract method signatures and class structure without method bodies.
|
|
59
|
+
# Much more compact than full source — typically 10-20% of original size.
|
|
60
|
+
def extract_signatures(content)
|
|
61
|
+
signatures = []
|
|
62
|
+
indent_stack = []
|
|
63
|
+
|
|
64
|
+
content.lines.each do |line|
|
|
65
|
+
process_signature_line(line, signatures, indent_stack)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
signatures.join
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns budget utilization stats.
|
|
72
|
+
def stats
|
|
73
|
+
{
|
|
74
|
+
budget: @budget,
|
|
75
|
+
tokens_used: @tokens_used,
|
|
76
|
+
utilization: @budget.positive? ? (@tokens_used.to_f / @budget).round(3) : 0.0,
|
|
77
|
+
full_files: @loaded_files.size,
|
|
78
|
+
signature_files: @signature_files.size
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def load_full_files(sorted, results, remaining)
|
|
85
|
+
sorted.each do |rel_path|
|
|
86
|
+
content = safe_read(rel_path)
|
|
87
|
+
next unless content
|
|
88
|
+
|
|
89
|
+
size = estimate_tokens(content)
|
|
90
|
+
next unless size <= remaining
|
|
91
|
+
|
|
92
|
+
results << { file: rel_path, content: content, mode: :full }
|
|
93
|
+
@loaded_files << rel_path
|
|
94
|
+
@tokens_used += size
|
|
95
|
+
remaining -= size
|
|
96
|
+
end
|
|
97
|
+
remaining
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def load_signature_files(sorted, results, remaining)
|
|
101
|
+
sorted.each do |rel_path|
|
|
102
|
+
next if @loaded_files.include?(rel_path)
|
|
103
|
+
|
|
104
|
+
content = safe_read(rel_path)
|
|
105
|
+
next unless content
|
|
106
|
+
|
|
107
|
+
sigs = extract_signatures(content)
|
|
108
|
+
sig_size = estimate_tokens(sigs)
|
|
109
|
+
next unless sig_size <= remaining
|
|
110
|
+
|
|
111
|
+
results << { file: rel_path, content: sigs, mode: :signatures }
|
|
112
|
+
@signature_files << rel_path
|
|
113
|
+
@tokens_used += sig_size
|
|
114
|
+
remaining -= sig_size
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def process_signature_line(line, signatures, indent_stack) # rubocop:disable Metrics/AbcSize -- signature extraction dispatch
|
|
119
|
+
stripped = line.strip
|
|
120
|
+
if signature_line?(stripped)
|
|
121
|
+
signatures << line
|
|
122
|
+
indent_stack << current_indent(line) if block_opener?(stripped)
|
|
123
|
+
elsif stripped == 'end' && indent_stack.any? && current_indent(line) <= indent_stack.last
|
|
124
|
+
signatures << line
|
|
125
|
+
indent_stack.pop
|
|
126
|
+
elsif class_or_module_line?(stripped)
|
|
127
|
+
signatures << line
|
|
128
|
+
indent_stack << current_indent(line)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def prioritize(files)
|
|
133
|
+
files.sort_by do |path|
|
|
134
|
+
basename = File.basename(path, '.*').downcase
|
|
135
|
+
priority = PRIORITY_MAP.find { |key, _| basename.include?(key) }&.last || 10
|
|
136
|
+
priority
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def signature_line?(stripped)
|
|
141
|
+
stripped.match?(/\A\s*(def\s|attr_|include\s|extend\s|has_|belongs_|validates|scope\s|delegate\s)/)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def class_or_module_line?(stripped)
|
|
145
|
+
stripped.match?(/\A\s*(class|module)\s/)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def block_opener?(stripped)
|
|
149
|
+
stripped.match?(/\Adef\s/)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def current_indent(line)
|
|
153
|
+
line.match(/\A(\s*)/)[1].length
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def safe_read(path)
|
|
157
|
+
File.read(path)
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def estimate_tokens(text)
|
|
163
|
+
(text.bytesize.to_f / CHARS_PER_TOKEN).ceil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
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
|
|
@@ -13,12 +13,16 @@ module RubynCode
|
|
|
13
13
|
attr_reader :total_input_tokens, :total_output_tokens
|
|
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
|
|
20
22
|
end
|
|
21
23
|
|
|
24
|
+
attr_writer :llm_client
|
|
25
|
+
|
|
22
26
|
# Accumulates token counts from an LLM response usage object.
|
|
23
27
|
#
|
|
24
28
|
# @param usage [LLM::Usage, #input_tokens] usage data from an LLM response
|
|
@@ -77,11 +81,9 @@ module RubynCode
|
|
|
77
81
|
end
|
|
78
82
|
|
|
79
83
|
# 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
|
-
)
|
|
84
|
+
return unless @llm_client
|
|
84
85
|
|
|
86
|
+
compactor = Compactor.new(llm_client: @llm_client, threshold: @threshold)
|
|
85
87
|
new_messages = compactor.auto_compact!(messages)
|
|
86
88
|
apply_compacted_messages(conversation, new_messages)
|
|
87
89
|
end
|
|
@@ -22,22 +22,27 @@ module RubynCode
|
|
|
22
22
|
|
|
23
23
|
tool_name_index = build_tool_name_index(messages)
|
|
24
24
|
candidates = tool_result_refs[0..-(keep_recent + 1)]
|
|
25
|
-
|
|
25
|
+
compact_candidates(candidates, tool_name_index, preserve_tools)
|
|
26
|
+
end
|
|
26
27
|
|
|
28
|
+
def self.compact_candidates(candidates, tool_name_index, preserve_tools)
|
|
29
|
+
compacted = 0
|
|
27
30
|
candidates.each do |ref|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
compacted += 1 if compact_single_ref(ref, tool_name_index, preserve_tools)
|
|
32
|
+
end
|
|
33
|
+
compacted
|
|
34
|
+
end
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
def self.compact_single_ref(ref, tool_name_index, preserve_tools) # rubocop:disable Naming/PredicateMethod -- returns boolean but is an action method
|
|
37
|
+
block = ref[:block]
|
|
38
|
+
content = extract_content(block)
|
|
39
|
+
return false if content.nil? || content.length < MIN_CONTENT_LENGTH
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
compacted += 1
|
|
38
|
-
end
|
|
41
|
+
tool_name = resolve_tool_name(block, tool_name_index)
|
|
42
|
+
return false if preserve_tools.include?(tool_name)
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
replace_content!(block, format(PLACEHOLDER_TEMPLATE, tool_name: tool_name || 'tool'))
|
|
45
|
+
true
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
# Collects all tool_result content blocks across user messages, preserving
|
|
@@ -70,19 +75,23 @@ module RubynCode
|
|
|
70
75
|
messages.each do |msg|
|
|
71
76
|
next unless msg[:role] == 'assistant' && msg[:content].is_a?(Array)
|
|
72
77
|
|
|
73
|
-
msg[:content].each
|
|
74
|
-
case block
|
|
75
|
-
when Hash
|
|
76
|
-
index[block[:id] || block['id']] = block[:name] || block['name'] if block_type(block) == 'tool_use'
|
|
77
|
-
when LLM::ToolUseBlock
|
|
78
|
-
index[block.id] = block.name
|
|
79
|
-
end
|
|
80
|
-
end
|
|
78
|
+
msg[:content].each { |block| index_tool_use(index, block) }
|
|
81
79
|
end
|
|
82
80
|
|
|
83
81
|
index
|
|
84
82
|
end
|
|
85
83
|
|
|
84
|
+
def self.index_tool_use(index, block)
|
|
85
|
+
case block
|
|
86
|
+
when Hash
|
|
87
|
+
return unless block_type(block) == 'tool_use'
|
|
88
|
+
|
|
89
|
+
index[block[:id] || block['id']] = block[:name] || block['name']
|
|
90
|
+
when LLM::ToolUseBlock
|
|
91
|
+
index[block.id] = block.name
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
86
95
|
def self.tool_result_block?(block)
|
|
87
96
|
case block
|
|
88
97
|
when Hash
|
|
@@ -128,6 +137,7 @@ module RubynCode
|
|
|
128
137
|
end
|
|
129
138
|
|
|
130
139
|
private_class_method :collect_tool_results, :build_tool_name_index,
|
|
140
|
+
:index_tool_use, :compact_candidates, :compact_single_ref,
|
|
131
141
|
:tool_result_block?, :block_type, :extract_content,
|
|
132
142
|
:resolve_tool_name, :replace_content!
|
|
133
143
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Context
|
|
5
|
+
# Extracts only the relevant table definitions from db/schema.rb
|
|
6
|
+
# based on which models are currently in context. Loading the full
|
|
7
|
+
# schema for a large Rails app can be 5-10K tokens; filtering to
|
|
8
|
+
# relevant tables typically reduces this to 200-500 tokens.
|
|
9
|
+
module SchemaFilter
|
|
10
|
+
TABLE_PATTERN = /create_table\s+"([^"]+)"/
|
|
11
|
+
END_PATTERN = /\A\s+end\s*\z/
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Returns schema definitions for only the specified table names.
|
|
15
|
+
#
|
|
16
|
+
# @param schema_path [String] path to db/schema.rb
|
|
17
|
+
# @param table_names [Array<String>] table names to include
|
|
18
|
+
# @return [String] filtered schema content
|
|
19
|
+
def filter(schema_path, table_names:)
|
|
20
|
+
return '' if table_names.empty?
|
|
21
|
+
return '' unless File.exist?(schema_path)
|
|
22
|
+
|
|
23
|
+
lines = File.readlines(schema_path)
|
|
24
|
+
extract_tables(lines, table_names.to_set(&:to_s))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Derives table names from model class names using Rails conventions.
|
|
28
|
+
#
|
|
29
|
+
# @param model_names [Array<String>] e.g., ["User", "OrderItem"]
|
|
30
|
+
# @return [Array<String>] e.g., ["users", "order_items"]
|
|
31
|
+
def tableize(model_names)
|
|
32
|
+
model_names.map { |name| "#{name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}s" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Convenience: filter schema by model names instead of table names.
|
|
36
|
+
def filter_for_models(schema_path, model_names:)
|
|
37
|
+
tables = tableize(model_names)
|
|
38
|
+
filter(schema_path, table_names: tables)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def extract_tables(lines, table_set)
|
|
44
|
+
result = []
|
|
45
|
+
capturing = false
|
|
46
|
+
|
|
47
|
+
lines.each do |line|
|
|
48
|
+
match = TABLE_PATTERN.match(line)
|
|
49
|
+
capturing = true if match && table_set.include?(match[1])
|
|
50
|
+
|
|
51
|
+
result << line if capturing
|
|
52
|
+
|
|
53
|
+
if capturing && END_PATTERN.match?(line)
|
|
54
|
+
capturing = false
|
|
55
|
+
result << "\n"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
result.join
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -106,34 +106,11 @@ module RubynCode
|
|
|
106
106
|
#
|
|
107
107
|
# @yield the block to execute
|
|
108
108
|
# @return [Object] the block's return value
|
|
109
|
-
def transaction
|
|
109
|
+
def transaction(&block)
|
|
110
110
|
synchronize do
|
|
111
|
-
|
|
112
|
-
begin_top_level_transaction
|
|
113
|
-
else
|
|
114
|
-
begin_savepoint
|
|
115
|
-
end
|
|
116
|
-
|
|
111
|
+
@transaction_depth.zero? ? begin_top_level_transaction : begin_savepoint
|
|
117
112
|
@transaction_depth += 1
|
|
118
|
-
|
|
119
|
-
result = yield
|
|
120
|
-
if @transaction_depth == 1
|
|
121
|
-
@db.execute('COMMIT')
|
|
122
|
-
else
|
|
123
|
-
@db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
|
|
124
|
-
end
|
|
125
|
-
result
|
|
126
|
-
rescue StandardError => e
|
|
127
|
-
if @transaction_depth == 1
|
|
128
|
-
@db.execute('ROLLBACK')
|
|
129
|
-
else
|
|
130
|
-
@db.execute("ROLLBACK TO SAVEPOINT sp_#{@transaction_depth}")
|
|
131
|
-
@db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
|
|
132
|
-
end
|
|
133
|
-
raise e
|
|
134
|
-
ensure
|
|
135
|
-
@transaction_depth -= 1
|
|
136
|
-
end
|
|
113
|
+
execute_transaction_body(&block)
|
|
137
114
|
end
|
|
138
115
|
end
|
|
139
116
|
|
|
@@ -171,6 +148,34 @@ module RubynCode
|
|
|
171
148
|
def begin_savepoint
|
|
172
149
|
@db.execute("SAVEPOINT sp_#{@transaction_depth + 1}")
|
|
173
150
|
end
|
|
151
|
+
|
|
152
|
+
def execute_transaction_body
|
|
153
|
+
result = yield
|
|
154
|
+
commit_or_release
|
|
155
|
+
result
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
rollback_or_release
|
|
158
|
+
raise e
|
|
159
|
+
ensure
|
|
160
|
+
@transaction_depth -= 1
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def commit_or_release
|
|
164
|
+
if @transaction_depth == 1
|
|
165
|
+
@db.execute('COMMIT')
|
|
166
|
+
else
|
|
167
|
+
@db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def rollback_or_release
|
|
172
|
+
if @transaction_depth == 1
|
|
173
|
+
@db.execute('ROLLBACK')
|
|
174
|
+
else
|
|
175
|
+
@db.execute("ROLLBACK TO SAVEPOINT sp_#{@transaction_depth}")
|
|
176
|
+
@db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
174
179
|
end
|
|
175
180
|
end
|
|
176
181
|
end
|