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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. 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-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,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
- # Keep first message + last N messages, snip the middle
27
- first = messages.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 - 1
32
+ snipped_count = messages.size - keep_recent - anchors.size
30
33
 
31
34
  collapsed = [
32
- first,
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
- 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
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
- MICRO_COMPACT_RATIO = 0.7
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 * MICRO_COMPACT_RATIO)
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
- compactor = Compactor.new(
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)