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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. 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-20250514' if llm_client.respond_to?(:chat)
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
- def initialize(threshold: Config::Defaults::CONTEXT_THRESHOLD_TOKENS)
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
- compactor = Compactor.new(
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
- compacted = 0
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
- block = ref[:block]
29
- content = extract_content(block)
30
- next if content.nil? || content.length < MIN_CONTENT_LENGTH
31
+ compacted += 1 if compact_single_ref(ref, tool_name_index, preserve_tools)
32
+ end
33
+ compacted
34
+ end
31
35
 
32
- tool_name = resolve_tool_name(block, tool_name_index)
33
- next if preserve_tools.include?(tool_name)
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
- placeholder = format(PLACEHOLDER_TEMPLATE, tool_name: tool_name || 'tool')
36
- replace_content!(block, placeholder)
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
- compacted
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 do |block|
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
- if @transaction_depth.zero?
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
- begin
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