rubyn-code 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +263 -21
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +34 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +11 -1
  7. data/lib/rubyn_code/agent/loop.rb +14 -3
  8. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
  10. data/lib/rubyn_code/agent/tool_processor.rb +25 -3
  11. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  12. data/lib/rubyn_code/auth/token_store.rb +50 -9
  13. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  14. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  15. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  16. data/lib/rubyn_code/cli/app.rb +116 -11
  17. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  18. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  19. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  20. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  21. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  22. data/lib/rubyn_code/cli/commands/provider.rb +124 -0
  23. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  24. data/lib/rubyn_code/cli/commands/skill.rb +54 -3
  25. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  27. data/lib/rubyn_code/cli/first_run.rb +159 -0
  28. data/lib/rubyn_code/cli/repl.rb +15 -0
  29. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +74 -1
  32. data/lib/rubyn_code/config/defaults.rb +3 -0
  33. data/lib/rubyn_code/config/schema.json +49 -0
  34. data/lib/rubyn_code/config/settings.rb +12 -6
  35. data/lib/rubyn_code/config/validator.rb +63 -0
  36. data/lib/rubyn_code/context/context_budget.rb +18 -2
  37. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  38. data/lib/rubyn_code/context/manager.rb +37 -3
  39. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  40. data/lib/rubyn_code/hooks/registry.rb +4 -0
  41. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  42. data/lib/rubyn_code/ide/client.rb +110 -0
  43. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  44. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  45. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  46. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  47. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  48. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  49. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  50. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
  51. data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
  52. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  53. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  54. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  55. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  56. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  57. data/lib/rubyn_code/ide/handlers.rb +76 -0
  58. data/lib/rubyn_code/ide/protocol.rb +112 -0
  59. data/lib/rubyn_code/ide/server.rb +186 -0
  60. data/lib/rubyn_code/index/codebase_index.rb +69 -2
  61. data/lib/rubyn_code/learning/extractor.rb +4 -2
  62. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  63. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  64. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  65. data/lib/rubyn_code/llm/client.rb +29 -4
  66. data/lib/rubyn_code/llm/model_router.rb +2 -1
  67. data/lib/rubyn_code/mcp/config.rb +2 -1
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  69. data/lib/rubyn_code/memory/search.rb +1 -0
  70. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  71. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  72. data/lib/rubyn_code/self_test.rb +316 -0
  73. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  74. data/lib/rubyn_code/skills/catalog.rb +76 -0
  75. data/lib/rubyn_code/skills/document.rb +8 -2
  76. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  77. data/lib/rubyn_code/skills/loader.rb +43 -0
  78. data/lib/rubyn_code/skills/matcher.rb +89 -0
  79. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  80. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  81. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  82. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  83. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  84. data/lib/rubyn_code/tasks/models.rb +1 -0
  85. data/lib/rubyn_code/tools/base.rb +13 -0
  86. data/lib/rubyn_code/tools/bash.rb +5 -0
  87. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  88. data/lib/rubyn_code/tools/executor.rb +65 -8
  89. data/lib/rubyn_code/tools/glob.rb +6 -0
  90. data/lib/rubyn_code/tools/grep.rb +7 -0
  91. data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
  92. data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
  93. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  94. data/lib/rubyn_code/tools/output_compressor.rb +9 -7
  95. data/lib/rubyn_code/tools/read_file.rb +6 -0
  96. data/lib/rubyn_code/tools/registry.rb +11 -0
  97. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  98. data/lib/rubyn_code/tools/web_search.rb +2 -1
  99. data/lib/rubyn_code/tools/write_file.rb +17 -0
  100. data/lib/rubyn_code/version.rb +1 -1
  101. data/lib/rubyn_code.rb +34 -0
  102. data/skills/rubyn_self_test.md +88 -1
  103. metadata +43 -1
@@ -11,6 +11,7 @@ module RubynCode
11
11
  setup_services!
12
12
  setup_executor_callbacks!
13
13
  setup_hooks!
14
+ setup_mcp_servers!
14
15
  setup_agent_loop!
15
16
  end
16
17
 
@@ -41,9 +42,41 @@ module RubynCode
41
42
  @budget_enforcer = Observability::BudgetEnforcer.new(@db, session_id: current_session_id)
42
43
  @background_worker = Background::Worker.new(project_root: @project_root)
43
44
  @skill_loader = Skills::Loader.new(Skills::Catalog.new(skill_dirs))
45
+ @skill_matcher = build_skill_matcher
46
+ @web_skill_autoload = build_web_skill_autoload
44
47
  @session_persistence = Memory::SessionPersistence.new(@db)
45
48
  end
46
49
 
50
+ def build_skill_matcher
51
+ return nil unless skills_autoload_enabled?
52
+
53
+ Skills::Matcher.new(catalog: @skill_loader.catalog, project_root: @project_root)
54
+ end
55
+
56
+ def build_web_skill_autoload
57
+ return nil unless @skill_matcher && skills_autoload_enabled?
58
+
59
+ Skills::RegistryAutoload.new(
60
+ loader: @skill_loader,
61
+ matcher: @skill_matcher,
62
+ on_fetching: on_pack_fetching_callback
63
+ )
64
+ end
65
+
66
+ def skills_autoload_enabled?
67
+ Config::Settings.new.skills_autoload
68
+ rescue Config::Settings::LoadError
69
+ true
70
+ end
71
+
72
+ def on_skills_autoloaded_callback
73
+ ->(names) { @renderer.system_message("📚 Loaded: #{names.join(' · ')}") }
74
+ end
75
+
76
+ def on_pack_fetching_callback
77
+ ->(name) { @renderer.system_message("📥 Fetching skill pack '#{name}' from registry…") }
78
+ end
79
+
47
80
  def setup_executor_callbacks!
48
81
  @tool_executor.llm_client = @llm_client
49
82
  @tool_executor.background_worker = @background_worker
@@ -106,7 +139,10 @@ module RubynCode
106
139
  on_tool_call: ->(name, params) { handle_on_tool_call(name, params) },
107
140
  on_tool_result: ->(name, result, _is_error = false) { handle_on_tool_result(name, result) },
108
141
  on_text: ->(text) { handle_on_text(text) },
109
- skill_loader: @skill_loader, project_root: @project_root
142
+ on_skills_autoloaded: on_skills_autoloaded_callback,
143
+ skill_loader: @skill_loader, skill_matcher: @skill_matcher,
144
+ web_skill_autoload: @web_skill_autoload,
145
+ project_root: @project_root
110
146
  )
111
147
  end
112
148
 
@@ -138,8 +174,45 @@ module RubynCode
138
174
  dirs << project_skills if Dir.exist?(project_skills)
139
175
  user_skills = File.join(Config::Defaults::HOME_DIR, 'skills')
140
176
  dirs << user_skills if Dir.exist?(user_skills)
177
+ skill_packs = File.join(Config::Defaults::HOME_DIR, 'skill-packs')
178
+ dirs << skill_packs if Dir.exist?(skill_packs)
141
179
  dirs
142
180
  end
181
+
182
+ # ── MCP Server Wiring ─────────────────────────────────────────
183
+
184
+ def setup_mcp_servers!
185
+ @mcp_clients = []
186
+ server_configs = MCP::Config.load(@project_root)
187
+ return if server_configs.empty?
188
+
189
+ server_configs.each do |config|
190
+ connect_mcp_server(config)
191
+ end
192
+
193
+ at_exit { disconnect_mcp_clients! unless defined?(RSpec) }
194
+ end
195
+
196
+ def connect_mcp_server(config)
197
+ client = MCP::Client.from_config(config)
198
+ client.connect!
199
+ MCP::ToolBridge.bridge(client)
200
+ @mcp_clients << client
201
+ @renderer.info("MCP server '#{config[:name]}' connected (#{client.tools.size} tools)")
202
+ rescue StandardError => e
203
+ warn "[MCP] Failed to connect '#{config[:name]}': #{e.message}"
204
+ end
205
+
206
+ def disconnect_mcp_clients!
207
+ return if @mcp_clients.nil? || @mcp_clients.empty?
208
+
209
+ @mcp_clients.each do |client|
210
+ client.disconnect!
211
+ rescue StandardError => e
212
+ warn "[MCP] Error disconnecting '#{client.name}': #{e.message}"
213
+ end
214
+ @mcp_clients.clear
215
+ end
143
216
  end
144
217
  end
145
218
  end
@@ -12,6 +12,7 @@ module RubynCode
12
12
 
13
13
  DEFAULT_PROVIDER = 'anthropic'
14
14
  DEFAULT_MODEL = 'claude-opus-4-6'
15
+ MODEL_MODE = 'auto' # 'auto' or 'manual'
15
16
  MAX_ITERATIONS = 200
16
17
  MAX_SUB_AGENT_ITERATIONS = 200
17
18
  MAX_EXPLORE_AGENT_ITERATIONS = 200
@@ -30,6 +31,8 @@ module RubynCode
30
31
  POLL_INTERVAL = 5
31
32
  IDLE_TIMEOUT = 60
32
33
 
34
+ SKILLS_AUTOLOAD = true
35
+
33
36
  SESSION_BUDGET_USD = 5.00
34
37
  DAILY_BUDGET_USD = 10.00
35
38
 
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "provider": {
6
+ "type": "string",
7
+ "minLength": 1
8
+ },
9
+ "model": {
10
+ "type": "string",
11
+ "minLength": 1
12
+ },
13
+ "model_mode": {
14
+ "type": "string",
15
+ "enum": ["auto", "manual"]
16
+ },
17
+ "max_iterations": {
18
+ "type": "integer",
19
+ "minimum": 1,
20
+ "maximum": 1000
21
+ },
22
+ "max_sub_agent_iterations": {
23
+ "type": "integer",
24
+ "minimum": 1,
25
+ "maximum": 500
26
+ },
27
+ "max_output_chars": {
28
+ "type": "integer",
29
+ "minimum": 1000,
30
+ "maximum": 1000000
31
+ },
32
+ "context_threshold_tokens": {
33
+ "type": "integer",
34
+ "minimum": 10000,
35
+ "maximum": 200000
36
+ },
37
+ "session_budget_usd": {
38
+ "type": "number",
39
+ "minimum": 0.1,
40
+ "maximum": 100
41
+ },
42
+ "daily_budget_usd": {
43
+ "type": "number",
44
+ "minimum": 0.5,
45
+ "maximum": 500
46
+ }
47
+ },
48
+ "additionalProperties": true
49
+ }
@@ -10,17 +10,19 @@ module RubynCode
10
10
  class LoadError < StandardError; end
11
11
 
12
12
  CONFIGURABLE_KEYS = %i[
13
- provider model max_iterations max_sub_agent_iterations max_output_chars
13
+ provider model model_mode max_iterations max_sub_agent_iterations max_output_chars
14
14
  context_threshold_tokens micro_compact_keep_recent
15
15
  poll_interval idle_timeout
16
16
  session_budget_usd daily_budget_usd
17
17
  oauth_client_id oauth_redirect_uri oauth_authorize_url
18
18
  oauth_token_url oauth_scopes
19
+ skills_autoload
19
20
  ].freeze
20
21
 
21
22
  DEFAULT_MAP = {
22
23
  provider: Defaults::DEFAULT_PROVIDER,
23
24
  model: Defaults::DEFAULT_MODEL,
25
+ model_mode: Defaults::MODEL_MODE,
24
26
  max_iterations: Defaults::MAX_ITERATIONS,
25
27
  max_sub_agent_iterations: Defaults::MAX_SUB_AGENT_ITERATIONS,
26
28
  max_output_chars: Defaults::MAX_OUTPUT_CHARS,
@@ -34,7 +36,8 @@ module RubynCode
34
36
  oauth_redirect_uri: Defaults::OAUTH_REDIRECT_URI,
35
37
  oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
36
38
  oauth_token_url: Defaults::OAUTH_TOKEN_URL,
37
- oauth_scopes: Defaults::OAUTH_SCOPES
39
+ oauth_scopes: Defaults::OAUTH_SCOPES,
40
+ skills_autoload: Defaults::SKILLS_AUTOLOAD
38
41
  }.freeze
39
42
 
40
43
  attr_reader :config_path, :data
@@ -114,10 +117,11 @@ module RubynCode
114
117
  # @param env_key [String, nil] environment variable for the API key
115
118
  # @param models [Array<String>] available model names
116
119
  # @param pricing [Hash] model => [input_rate, output_rate]
117
- def add_provider(name, base_url:, env_key: nil, models: [], pricing: {})
120
+ # @param api_format [String, nil] API format ('openai' or 'anthropic')
121
+ def add_provider(name, base_url:, env_key: nil, models: [], pricing: {}, api_format: nil) # rubocop:disable Metrics/ParameterLists -- all optional kwargs with defaults
118
122
  @data['providers'] ||= {}
119
123
  @data['providers'][name.to_s] = build_provider_hash(
120
- base_url: base_url, env_key: env_key, models: models, pricing: pricing
124
+ base_url: base_url, env_key: env_key, models: models, pricing: pricing, api_format: api_format
121
125
  )
122
126
  save!
123
127
  end
@@ -158,7 +162,8 @@ module RubynCode
158
162
 
159
163
  # Backfills missing 'models' keys into existing provider configs.
160
164
  # Never overwrites user-set values — only adds what's missing.
161
- def backfill_provider_models! # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- iterates providers with guard clauses
165
+ # -- iterates providers with guard clauses
166
+ def backfill_provider_models!
162
167
  providers = @data['providers']
163
168
  return unless providers.is_a?(Hash)
164
169
 
@@ -176,8 +181,9 @@ module RubynCode
176
181
  nil
177
182
  end
178
183
 
179
- def build_provider_hash(base_url:, env_key:, models:, pricing:)
184
+ def build_provider_hash(base_url:, env_key:, models:, pricing:, api_format: nil)
180
185
  hash = { 'base_url' => base_url }
186
+ hash['api_format'] = api_format if api_format
181
187
  hash['env_key'] = env_key if env_key
182
188
  hash['models'] = models unless models.empty?
183
189
  hash['pricing'] = pricing unless pricing.empty?
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'json_schemer'
5
+
6
+ module RubynCode
7
+ module Config
8
+ class Validator
9
+ SCHEMA_PATH = File.expand_path('schema.json', __dir__)
10
+
11
+ def initialize
12
+ @raw_schema = JSON.parse(File.read(SCHEMA_PATH))
13
+ @schemer = JSONSchemer.schema(@raw_schema)
14
+ end
15
+
16
+ # Validates a single config key/value pair against the schema.
17
+ #
18
+ # @param key [String] the config key
19
+ # @param value [Object] the value to validate
20
+ # @return [Hash] { valid: true/false, errors: [String] }
21
+ def validate(key, value)
22
+ # If the key has no schema definition, accept any value
23
+ properties = @raw_schema.fetch('properties', {})
24
+ unless properties.key?(key.to_s)
25
+ return { valid: true, errors: [] }
26
+ end
27
+
28
+ doc = { key.to_s => value }
29
+ errors = @schemer.validate(doc).select { |e| e['data_pointer'] == "/#{key}" }
30
+
31
+ if errors.empty?
32
+ { valid: true, errors: [] }
33
+ else
34
+ messages = errors.map { |e| format_error(key, e) }
35
+ { valid: false, errors: messages }
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def format_error(key, error)
42
+ detail = error['type']
43
+ schema_node = error.fetch('schema', {})
44
+
45
+ parts = ["#{key}: invalid value"]
46
+ parts << "(expected #{detail})" if detail
47
+
48
+ if schema_node.key?('minimum') || schema_node.key?('maximum')
49
+ range_parts = []
50
+ range_parts << "min #{schema_node['minimum']}" if schema_node.key?('minimum')
51
+ range_parts << "max #{schema_node['maximum']}" if schema_node.key?('maximum')
52
+ parts << "[#{range_parts.join(', ')}]"
53
+ end
54
+
55
+ if schema_node.key?('enum')
56
+ parts << "allowed: #{schema_node['enum'].join(', ')}"
57
+ end
58
+
59
+ parts.join(' ')
60
+ end
61
+ end
62
+ end
63
+ end
@@ -25,8 +25,9 @@ module RubynCode
25
25
 
26
26
  attr_reader :loaded_files, :signature_files, :tokens_used
27
27
 
28
- def initialize(budget: DEFAULT_BUDGET)
28
+ def initialize(budget: DEFAULT_BUDGET, codebase_index: nil)
29
29
  @budget = budget
30
+ @codebase_index = codebase_index
30
31
  @loaded_files = []
31
32
  @signature_files = []
32
33
  @tokens_used = 0
@@ -34,6 +35,10 @@ module RubynCode
34
35
 
35
36
  # Load context for a primary file, filling budget with related files.
36
37
  # Returns array of { file:, content:, mode: :full|:signatures }
38
+ #
39
+ # When a codebase_index is available and no related_files are supplied,
40
+ # uses impact_analysis to auto-discover related files (specs,
41
+ # associated models, controllers, etc.).
37
42
  def load_for(file_path, related_files: [])
38
43
  results = []
39
44
 
@@ -46,6 +51,9 @@ module RubynCode
46
51
  @loaded_files << file_path
47
52
  results << { file: file_path, content: primary_content, mode: :full }
48
53
 
54
+ # Auto-discover related files from the index when none supplied
55
+ related_files = discover_related_files(file_path) if related_files.empty? && @codebase_index
56
+
49
57
  # Sort related files by priority and fill remaining budget
50
58
  sorted = prioritize(related_files)
51
59
  remaining = @budget - @tokens_used
@@ -81,6 +89,13 @@ module RubynCode
81
89
 
82
90
  private
83
91
 
92
+ def discover_related_files(file_path)
93
+ analysis = @codebase_index.impact_analysis(file_path)
94
+ analysis[:affected_files].reject { |f| f == file_path }
95
+ rescue StandardError
96
+ []
97
+ end
98
+
84
99
  def load_full_files(sorted, results, remaining)
85
100
  sorted.each do |rel_path|
86
101
  content = safe_read(rel_path)
@@ -115,7 +130,8 @@ module RubynCode
115
130
  end
116
131
  end
117
132
 
118
- def process_signature_line(line, signatures, indent_stack) # rubocop:disable Metrics/AbcSize -- signature extraction dispatch
133
+ # -- signature extraction dispatch
134
+ def process_signature_line(line, signatures, indent_stack)
119
135
  stripped = line.strip
120
136
  if signature_line?(stripped)
121
137
  signatures << line
@@ -23,13 +23,16 @@ module RubynCode
23
23
  def self.call(messages, threshold:, keep_recent: 6)
24
24
  return nil if messages.size <= keep_recent + 2
25
25
 
26
- # 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
@@ -10,7 +10,7 @@ module RubynCode
10
10
  class Manager
11
11
  CHARS_PER_TOKEN = 4
12
12
 
13
- attr_reader :total_input_tokens, :total_output_tokens
13
+ attr_reader :total_input_tokens, :total_output_tokens, :current_turn
14
14
 
15
15
  # @param threshold [Integer] estimated token count that triggers auto-compaction
16
16
  # @param llm_client [LLM::Client, nil] needed for LLM-driven compaction
@@ -19,10 +19,18 @@ module RubynCode
19
19
  @llm_client = llm_client
20
20
  @total_input_tokens = 0
21
21
  @total_output_tokens = 0
22
+ @last_compaction_turn = -1
23
+ @current_turn = 0
22
24
  end
23
25
 
24
26
  attr_writer :llm_client
25
27
 
28
+ # Advances the turn counter. Call once per iteration so that
29
+ # duplicate compaction calls within the same turn are skipped.
30
+ def advance_turn!
31
+ @current_turn += 1
32
+ end
33
+
26
34
  # Accumulates token counts from an LLM response usage object.
27
35
  #
28
36
  # @param usage [LLM::Usage, #input_tokens] usage data from an LLM response
@@ -60,16 +68,22 @@ module RubynCode
60
68
  # Fraction of the compaction threshold at which micro-compact kicks in.
61
69
  # Running it too early busts the prompt cache prefix (mutated messages
62
70
  # change the hash, invalidating server-side cached tokens).
63
- 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
64
75
 
65
76
  def check_compaction!(conversation)
77
+ # Guard: skip if compaction already succeeded this turn
78
+ return if @last_compaction_turn == @current_turn
79
+
66
80
  messages = conversation.messages
67
81
 
68
82
  # Step 1: Zero-cost micro-compact — but only when we're approaching
69
83
  # the compaction threshold. Running it every turn mutates old messages,
70
84
  # which invalidates the prompt cache prefix and wastes tokens.
71
85
  est = estimated_tokens(messages)
72
- MicroCompact.call(messages) if est > (@threshold * MICRO_COMPACT_RATIO)
86
+ MicroCompact.call(messages) if est > (@threshold * micro_compact_ratio)
73
87
 
74
88
  return unless needs_compaction?(messages)
75
89
 
@@ -77,6 +91,7 @@ module RubynCode
77
91
  collapsed = ContextCollapse.call(messages, threshold: @threshold)
78
92
  if collapsed
79
93
  apply_compacted_messages(conversation, collapsed)
94
+ @last_compaction_turn = @current_turn
80
95
  return
81
96
  end
82
97
 
@@ -86,6 +101,7 @@ module RubynCode
86
101
  compactor = Compactor.new(llm_client: @llm_client, threshold: @threshold)
87
102
  new_messages = compactor.auto_compact!(messages)
88
103
  apply_compacted_messages(conversation, new_messages)
104
+ @last_compaction_turn = @current_turn
89
105
  end
90
106
 
91
107
  # Resets cumulative token counters to zero.
@@ -94,10 +110,28 @@ module RubynCode
94
110
  def reset!
95
111
  @total_input_tokens = 0
96
112
  @total_output_tokens = 0
113
+ @last_compaction_turn = -1
114
+ @current_turn = 0
97
115
  end
98
116
 
99
117
  private
100
118
 
119
+ # Returns the micro-compact ratio based on the active provider.
120
+ # Providers with prompt caching (Anthropic) use a higher ratio to
121
+ # preserve cached prefixes; providers without caching compact earlier.
122
+ def micro_compact_ratio
123
+ return MICRO_COMPACT_RATIO_UNCACHED if uncached_provider?
124
+
125
+ MICRO_COMPACT_RATIO_CACHED
126
+ end
127
+
128
+ def uncached_provider?
129
+ return false unless @llm_client
130
+
131
+ provider = @llm_client.provider_name if @llm_client.respond_to?(:provider_name)
132
+ %w[openai openai_compatible].include?(provider)
133
+ end
134
+
101
135
  def apply_compacted_messages(conversation, new_messages)
102
136
  if conversation.respond_to?(:replace_messages)
103
137
  conversation.replace_messages(new_messages)
@@ -68,7 +68,7 @@ module RubynCode
68
68
  ]
69
69
 
70
70
  options = {}
71
- options[:model] = 'claude-sonnet-4-20250514' if llm_client.respond_to?(:chat)
71
+ options[:model] = 'claude-sonnet-4-6' if llm_client.respond_to?(:chat)
72
72
 
73
73
  response = llm_client.chat(messages: summary_messages, **options)
74
74
 
@@ -17,6 +17,10 @@ module RubynCode
17
17
  on_stall
18
18
  on_error
19
19
  on_session_end
20
+ session_start
21
+ user_prompt_submit
22
+ permission_request
23
+ stop
20
24
  ].freeze
21
25
 
22
26
  Hook = Data.define(:callable, :priority)