rubyn-code 0.5.1 → 0.8.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -4
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +100 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  6. data/lib/rubyn_code/agent/llm_caller.rb +7 -3
  7. data/lib/rubyn_code/agent/loop.rb +106 -12
  8. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
  10. data/lib/rubyn_code/agent/tool_processor.rb +13 -1
  11. data/lib/rubyn_code/auth/oauth.rb +1 -1
  12. data/lib/rubyn_code/auth/token_store.rb +49 -4
  13. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  14. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  15. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  16. data/lib/rubyn_code/chisel/debt.rb +65 -0
  17. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  18. data/lib/rubyn_code/chisel.rb +127 -0
  19. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  20. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  21. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  23. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  24. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  25. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  26. data/lib/rubyn_code/cli/commands/context.rb +30 -1
  27. data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
  28. data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
  29. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  30. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  31. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  32. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  33. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  34. data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
  35. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  36. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  37. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  38. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  39. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  40. data/lib/rubyn_code/cli/first_run.rb +1 -1
  41. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  42. data/lib/rubyn_code/cli/mention_expander.rb +111 -0
  43. data/lib/rubyn_code/cli/renderer.rb +3 -2
  44. data/lib/rubyn_code/cli/repl.rb +68 -15
  45. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  46. data/lib/rubyn_code/cli/repl_setup.rb +17 -7
  47. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  48. data/lib/rubyn_code/cli/version_check.rb +10 -3
  49. data/lib/rubyn_code/config/defaults.rb +14 -1
  50. data/lib/rubyn_code/config/schema.json +9 -0
  51. data/lib/rubyn_code/config/settings.rb +19 -2
  52. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  53. data/lib/rubyn_code/context/manager.rb +29 -12
  54. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  55. data/lib/rubyn_code/debug.rb +11 -5
  56. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  57. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  58. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  59. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  60. data/lib/rubyn_code/hooks/response.rb +83 -0
  61. data/lib/rubyn_code/hooks/runner.rb +61 -3
  62. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  63. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  64. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  65. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  66. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  67. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  69. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  70. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  71. data/lib/rubyn_code/index/codebase_index.rb +103 -4
  72. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  73. data/lib/rubyn_code/learning/injector.rb +1 -2
  74. data/lib/rubyn_code/learning/porter.rb +129 -0
  75. data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
  76. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  77. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  78. data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
  79. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  80. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  81. data/lib/rubyn_code/llm/client.rb +16 -3
  82. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  83. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  84. data/lib/rubyn_code/llm/model_router.rb +4 -4
  85. data/lib/rubyn_code/mcp/client.rb +59 -0
  86. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  87. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  88. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  90. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  91. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  92. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  93. data/lib/rubyn_code/memory/search.rb +9 -5
  94. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  95. data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
  96. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  97. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  98. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  99. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  100. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  101. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  102. data/lib/rubyn_code/tasks/models.rb +0 -16
  103. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  104. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  105. data/lib/rubyn_code/teams/manager.rb +83 -5
  106. data/lib/rubyn_code/teams/teammate.rb +3 -14
  107. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  108. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  109. data/lib/rubyn_code/tools/bash.rb +3 -3
  110. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  111. data/lib/rubyn_code/tools/executor.rb +9 -4
  112. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  113. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  114. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  115. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  116. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  117. data/lib/rubyn_code/tools/web_search.rb +4 -1
  118. data/lib/rubyn_code/version.rb +1 -1
  119. data/lib/rubyn_code.rb +57 -9
  120. data/skills/rubyn_self_test.md +462 -14
  121. data/skills/self_test/chisel_smoke.rb +84 -0
  122. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  123. metadata +45 -6
  124. data/lib/rubyn_code/context/context_budget.rb +0 -183
  125. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  126. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  127. data/lib/rubyn_code/llm/streaming.rb +0 -10
  128. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -24,8 +24,25 @@ module RubynCode
24
24
  Commands::Model, Commands::NewSession, Commands::Mcp,
25
25
  Commands::Provider, Commands::InstallSkills,
26
26
  Commands::RemoveSkills, Commands::Skills,
27
- Commands::Megaplan
27
+ Commands::Megaplan, Commands::Goal, Commands::Loop,
28
+ Commands::Agents, Commands::Learning, Commands::Rewind,
29
+ Commands::Chisel, Commands::ChiselReview, Commands::ChiselAudit,
30
+ Commands::ChiselDebt, Commands::ChiselGain, Commands::Think, Commands::Effort, Commands::Export
28
31
  ].each { |cmd| @command_registry.register(cmd) }
32
+ register_custom_commands!
33
+ end
34
+
35
+ # Load user-defined slash commands from .rubyn-code/commands/*.md and
36
+ # ~/.rubyn-code/commands/*.md. Built-in commands always win — a custom
37
+ # file can't shadow /help, /quit, etc.
38
+ def register_custom_commands!
39
+ Commands::CustomLoader.load_all(project_root: @project_root).each do |cmd|
40
+ next if @command_registry.known?(cmd.command_name)
41
+
42
+ @command_registry.register(cmd)
43
+ end
44
+ rescue StandardError => e
45
+ RubynCode::Debug.warn("Custom command load failed: #{e.message}")
29
46
  end
30
47
 
31
48
  def handle_command(command)
@@ -67,7 +84,9 @@ module RubynCode
67
84
  background_worker: @background_worker,
68
85
  permission_tier: @permission_tier,
69
86
  plan_mode: @plan_mode,
70
- message_handler: method(:handle_message)
87
+ message_handler: method(:handle_message),
88
+ hook_registry: @hook_registry,
89
+ checkpoint_manager: @checkpoint_manager
71
90
  )
72
91
  end
73
92
 
@@ -85,11 +104,66 @@ module RubynCode
85
104
  apply_provider(provider, rest[:model])
86
105
  in { action: :spawn_teammate, name: String => name, role: String => role }
87
106
  spawn_teammate(name, role)
107
+ in { action: :run_loop, interval:, max: Integer => max, payload: String => payload }
108
+ run_loop(interval, max, payload)
109
+ in { action: :rewound }
110
+ save_session!
88
111
  else
89
112
  # Unknown result hash — ignore
90
113
  end
91
114
  end
92
115
 
116
+ # Drive a /loop. Owned by the REPL so it runs on the main thread (Ctrl-C
117
+ # stops it) and can re-dispatch slash-command payloads.
118
+ def run_loop(interval, max, payload)
119
+ announce_loop(interval, max)
120
+ payload = decorate_self_paced(payload) unless interval
121
+ @current_loop = CLI::LoopRunner.new(
122
+ interval: interval, max_iterations: max,
123
+ runner: ->(_i) { run_loop_payload(payload) },
124
+ on_iteration: ->(n, total) { @renderer.system_message("🔁 loop #{n}/#{total}") }
125
+ )
126
+ completed = @current_loop.run
127
+ @renderer.info("🔁 Loop finished after #{completed} iteration#{'s' unless completed == 1}.")
128
+ rescue Interrupt
129
+ @renderer.warning('Loop interrupted.')
130
+ ensure
131
+ @current_loop = nil
132
+ end
133
+
134
+ def announce_loop(interval, max)
135
+ cadence = interval ? "every #{format_interval(interval)}" : 'self-paced'
136
+ @renderer.info("🔁 Looping #{cadence} (up to #{max}×). Press Ctrl-C to stop.")
137
+ end
138
+
139
+ # Execute one iteration. Slash-command payloads are re-dispatched; plain
140
+ # prompts go through the agent. Returns the agent's text so self-paced
141
+ # loops can detect the LOOP_DONE sentinel.
142
+ def run_loop_payload(payload)
143
+ if payload.start_with?('/')
144
+ name, *rest = payload.split
145
+ dispatch_slash_command(name, rest)
146
+ ''
147
+ else
148
+ handle_message(payload).to_s
149
+ end
150
+ end
151
+
152
+ def decorate_self_paced(payload)
153
+ return payload if payload.start_with?('/')
154
+
155
+ "#{payload}\n\n(You are in a self-paced loop. Keep handling this " \
156
+ 'recurring task. When it no longer needs to run, end your reply ' \
157
+ "with #{CLI::LoopRunner::DONE_SENTINEL}.)"
158
+ end
159
+
160
+ def format_interval(seconds)
161
+ return "#{seconds / 3600}h" if (seconds % 3600).zero? && seconds >= 3600
162
+ return "#{seconds / 60}m" if (seconds % 60).zero? && seconds >= 60
163
+
164
+ "#{seconds}s"
165
+ end
166
+
93
167
  def start_new_session(new_id)
94
168
  @session_id = new_id
95
169
  @skills_injected = false # re-inject skills on next message
@@ -29,11 +29,16 @@ module RubynCode
29
29
 
30
30
  def setup_core_services!
31
31
  @llm_client = LLM::Client.new
32
+ @llm_client.thinking_budget_tokens = Config::Settings.new.get('thinking_budget_tokens', 0).to_i
32
33
  @conversation = Agent::Conversation.new
33
34
  @tool_executor = Tools::Executor.new(project_root: @project_root)
34
35
  @context_manager = Context::Manager.new(llm_client: @llm_client)
35
36
  @hook_registry = Hooks::Registry.new
36
- @hook_runner = Hooks::Runner.new(registry: @hook_registry)
37
+ @external_hook_dispatcher = Hooks::ExternalDispatcher.new(project_root: @project_root)
38
+ @hook_runner = Hooks::Runner.new(
39
+ registry: @hook_registry,
40
+ external_dispatcher: @external_hook_dispatcher
41
+ )
37
42
  @stall_detector = Agent::LoopDetector.new
38
43
  end
39
44
 
@@ -45,6 +50,8 @@ module RubynCode
45
50
  @skill_matcher = build_skill_matcher
46
51
  @web_skill_autoload = build_web_skill_autoload
47
52
  @session_persistence = Memory::SessionPersistence.new(@db)
53
+ @mention_expander = MentionExpander.new(project_root: @project_root)
54
+ @checkpoint_manager = Checkpoint::Manager.new(project_root: @project_root)
48
55
  end
49
56
 
50
57
  def build_skill_matcher
@@ -127,6 +134,8 @@ module RubynCode
127
134
  def setup_hooks!
128
135
  Hooks::BuiltIn.register_all!(@hook_registry)
129
136
  Hooks::UserHooks.load!(@hook_registry, project_root: @project_root)
137
+ # Snapshot files before they are mutated so /rewind can restore them.
138
+ @hook_registry.on(:pre_tool_use, Checkpoint::Hook.new(manager: @checkpoint_manager), priority: 5)
130
139
  end
131
140
 
132
141
  def setup_agent_loop!
@@ -183,11 +192,11 @@ module RubynCode
183
192
 
184
193
  def setup_mcp_servers!
185
194
  @mcp_clients = []
186
- server_configs = MCP::Config.load(@project_root)
187
- return if server_configs.empty?
195
+ entries = MCP::Discovery.discover(@project_root)
196
+ return if entries.empty?
188
197
 
189
- server_configs.each do |config|
190
- connect_mcp_server(config)
198
+ entries.each do |entry|
199
+ connect_mcp_server(entry)
191
200
  end
192
201
 
193
202
  at_exit { disconnect_mcp_clients! unless defined?(RSpec) }
@@ -198,9 +207,10 @@ module RubynCode
198
207
  client.connect!
199
208
  MCP::ToolBridge.bridge(client)
200
209
  @mcp_clients << client
201
- @renderer.info("MCP server '#{config[:name]}' connected (#{client.tools.size} tools)")
210
+ source_tag = config.respond_to?(:source) && config.source == :project ? ' [project]' : ' [user]'
211
+ @renderer.info("MCP server '#{config.name}' connected#{source_tag} (#{client.tools.size} tools)")
202
212
  rescue StandardError => e
203
- warn "[MCP] Failed to connect '#{config[:name]}': #{e.message}"
213
+ warn "[MCP] Failed to connect '#{config.name}': #{e.message}"
204
214
  end
205
215
 
206
216
  def disconnect_mcp_clients!
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pastel'
4
- require 'rouge'
5
4
 
6
5
  module RubynCode
7
6
  module CLI
@@ -11,7 +10,6 @@ module RubynCode
11
10
  class StreamFormatter
12
11
  def initialize(_renderer = nil)
13
12
  @pastel = Pastel.new
14
- @rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
15
13
  @buffer = +''
16
14
  @in_code_block = false
17
15
  @code_lang = nil
@@ -98,6 +96,9 @@ module RubynCode
98
96
  end
99
97
 
100
98
  def output_highlighted_code
99
+ # Lazy so REPL boot never pays for rouge until a code block is rendered.
100
+ require 'rouge'
101
+ @rouge_formatter ||= Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
101
102
  lexer = Rouge::Lexer.find(@code_lang || 'ruby') || Rouge::Lexers::PlainText.new
102
103
  highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
103
104
  border = @pastel.dim(' │ ')
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
3
  require 'json'
5
4
 
6
5
  module RubynCode
@@ -16,6 +15,7 @@ module RubynCode
16
15
  def initialize(renderer:)
17
16
  @renderer = renderer
18
17
  @thread = nil
18
+ @notified = false
19
19
  end
20
20
 
21
21
  # Kicks off a background check. Call `notify` later to display results.
@@ -26,11 +26,17 @@ module RubynCode
26
26
  @thread.abort_on_exception = false
27
27
  end
28
28
 
29
- # Waits briefly for the check to finish and prints a message if outdated.
30
- def notify(timeout: 2)
29
+ # Prints a message if outdated. Non-blocking by default: if the check
30
+ # hasn't finished yet, returns immediately so it can be retried before
31
+ # the next prompt. Notifies at most once.
32
+ def notify(timeout: 0)
33
+ return if @notified
31
34
  return unless @thread
32
35
 
33
36
  @thread.join(timeout)
37
+ return if @thread.alive?
38
+
39
+ @notified = true
34
40
  return unless @result
35
41
 
36
42
  return unless newer?(@result, RubynCode::VERSION)
@@ -60,6 +66,7 @@ module RubynCode
60
66
  end
61
67
 
62
68
  def fetch_latest_version
69
+ require 'faraday'
63
70
  conn = Faraday.new do |f|
64
71
  f.options.timeout = 5
65
72
  f.options.open_timeout = 3
@@ -11,9 +11,14 @@ module RubynCode
11
11
  MEMORIES_DIR = File.join(HOME_DIR, 'memories')
12
12
 
13
13
  DEFAULT_PROVIDER = 'anthropic'
14
- DEFAULT_MODEL = 'claude-opus-4-6'
14
+ DEFAULT_MODEL = 'claude-opus-5'
15
15
  MODEL_MODE = 'auto' # 'auto' or 'manual'
16
16
  MAX_ITERATIONS = 200
17
+ # Hard ceiling when a Stop hook (e.g. an active /goal) keeps the agent
18
+ # working past MAX_ITERATIONS. A goal can legitimately need more tool
19
+ # turns than a single request; the GoalHook's own max-attempts valve is
20
+ # the real terminator — this only guards against a runaway loop.
21
+ GOAL_MAX_ITERATIONS = 2_000
17
22
  MAX_SUB_AGENT_ITERATIONS = 200
18
23
  MAX_EXPLORE_AGENT_ITERATIONS = 200
19
24
 
@@ -33,9 +38,17 @@ module RubynCode
33
38
 
34
39
  SKILLS_AUTOLOAD = true
35
40
 
41
+ # Chisel: opt-in "write the minimum that works" enforcement. Off by
42
+ # default — only changes agent behavior once the user turns it on.
43
+ CHISEL_MODE = 'off'
44
+ THINKING_BUDGET_TOKENS = 0
45
+
36
46
  SESSION_BUDGET_USD = 5.00
37
47
  DAILY_BUDGET_USD = 10.00
38
48
 
49
+ # Claude Code's credentials file (Linux/other — no system keychain)
50
+ CLAUDE_CREDENTIALS_FILE = File.expand_path('~/.claude/.credentials.json')
51
+
39
52
  OAUTH_CLIENT_ID = 'rubyn-code'
40
53
  OAUTH_REDIRECT_URI = 'http://localhost:19275/callback'
41
54
  OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
@@ -43,6 +43,15 @@
43
43
  "type": "number",
44
44
  "minimum": 0.5,
45
45
  "maximum": 500
46
+ },
47
+ "chisel_mode": {
48
+ "type": "string",
49
+ "enum": ["off", "lite", "full", "ultra"]
50
+ },
51
+ "thinking_budget_tokens": {
52
+ "type": "integer",
53
+ "minimum": 0,
54
+ "maximum": 200000
46
55
  }
47
56
  },
48
57
  "additionalProperties": true
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require 'tmpdir'
4
5
  require 'fileutils'
5
6
  require_relative 'defaults'
6
7
 
@@ -17,6 +18,8 @@ module RubynCode
17
18
  oauth_client_id oauth_redirect_uri oauth_authorize_url
18
19
  oauth_token_url oauth_scopes
19
20
  skills_autoload
21
+ chisel_mode
22
+ thinking_budget_tokens
20
23
  ].freeze
21
24
 
22
25
  DEFAULT_MAP = {
@@ -37,12 +40,20 @@ module RubynCode
37
40
  oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
38
41
  oauth_token_url: Defaults::OAUTH_TOKEN_URL,
39
42
  oauth_scopes: Defaults::OAUTH_SCOPES,
40
- skills_autoload: Defaults::SKILLS_AUTOLOAD
43
+ skills_autoload: Defaults::SKILLS_AUTOLOAD,
44
+ chisel_mode: Defaults::CHISEL_MODE,
45
+ thinking_budget_tokens: Defaults::THINKING_BUDGET_TOKENS
41
46
  }.freeze
42
47
 
43
48
  attr_reader :config_path, :data
44
49
 
45
50
  def initialize(config_path: Defaults::CONFIG_FILE)
51
+ # When tests run, isolate Settings from the developer's personal
52
+ # ~/.rubyn-code/config.yml so a stray `provider: minimax` can't
53
+ # shadow the test expectations. The test config lives in
54
+ # tmpdir, is process-pid-scoped, and is harmless if it leaks.
55
+ config_path = self.class.test_config_path if config_path == Defaults::CONFIG_FILE && ENV['RUBYN_TESTING']
56
+
46
57
  @config_path = config_path
47
58
  @data = {}
48
59
  ensure_home_directory!
@@ -51,6 +62,12 @@ module RubynCode
51
62
  backfill_provider_models!
52
63
  end
53
64
 
65
+ # @return [String] a per-pid path under tmpdir used when
66
+ # RUBYN_TESTING is set
67
+ def self.test_config_path
68
+ @test_config_path ||= File.join(Dir.tmpdir, "rubyn-test-config-#{Process.pid}.yml")
69
+ end
70
+
54
71
  # Define accessor methods for each configurable key
55
72
  CONFIGURABLE_KEYS.each do |key|
56
73
  define_method(key) do
@@ -141,7 +158,7 @@ module RubynCode
141
158
  DEFAULT_PROVIDER_MODELS = {
142
159
  'anthropic' => {
143
160
  'env_key' => 'ANTHROPIC_API_KEY',
144
- 'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-6' }
161
+ 'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-5', 'top' => 'claude-opus-5' }
145
162
  },
146
163
  'openai' => {
147
164
  'env_key' => 'OPENAI_API_KEY',
@@ -63,7 +63,7 @@ module RubynCode
63
63
  ]
64
64
 
65
65
  options = {}
66
- options[:model] = 'claude-sonnet-4-6' if llm_client.respond_to?(:chat)
66
+ options[:model] = 'claude-sonnet-5' if llm_client.respond_to?(:chat)
67
67
 
68
68
  response = llm_client.chat(messages: summary_messages, **options)
69
69
 
@@ -39,24 +39,30 @@ module RubynCode
39
39
  @total_output_tokens += usage.output_tokens.to_i
40
40
  end
41
41
 
42
- # Rough estimate of token count for a set of messages based on their
43
- # JSON-serialized character length (~4 chars per token).
42
+ # Rough estimate of token count based on JSON-serialized character
43
+ # length (~4 chars per token). Accepts either a raw messages array or
44
+ # an object exposing #estimated_json_chars (e.g. Agent::Conversation),
45
+ # which avoids re-serializing the whole history on every call.
44
46
  #
45
- # @param messages [Array<Hash>] conversation messages
47
+ # @param source [Array<Hash>, #estimated_json_chars] conversation messages
46
48
  # @return [Integer] estimated token count
47
- def estimated_tokens(messages)
48
- json = JSON.generate(messages)
49
- (json.length.to_f / CHARS_PER_TOKEN).ceil
49
+ def estimated_tokens(source)
50
+ chars = if source.respond_to?(:estimated_json_chars)
51
+ source.estimated_json_chars
52
+ else
53
+ JSON.generate(source).length
54
+ end
55
+ (chars.to_f / CHARS_PER_TOKEN).ceil
50
56
  rescue JSON::GeneratorError
51
57
  0
52
58
  end
53
59
 
54
60
  # Returns true if the estimated token count exceeds the threshold.
55
61
  #
56
- # @param messages [Array<Hash>] conversation messages
62
+ # @param source [Array<Hash>, #estimated_json_chars] conversation messages
57
63
  # @return [Boolean]
58
- def needs_compaction?(messages)
59
- estimated_tokens(messages) > @threshold
64
+ def needs_compaction?(source)
65
+ estimated_tokens(source) > @threshold
60
66
  end
61
67
 
62
68
  # Runs micro-compaction every turn and auto-compaction when the context
@@ -78,14 +84,17 @@ module RubynCode
78
84
  return if @last_compaction_turn == @current_turn
79
85
 
80
86
  messages = conversation.messages
87
+ estimate_source = conversation.respond_to?(:estimated_json_chars) ? conversation : messages
81
88
 
82
89
  # Step 1: Zero-cost micro-compact — but only when we're approaching
83
90
  # the compaction threshold. Running it every turn mutates old messages,
84
91
  # which invalidates the prompt cache prefix and wastes tokens.
85
- est = estimated_tokens(messages)
86
- MicroCompact.call(messages) if est > (@threshold * micro_compact_ratio)
92
+ est = estimated_tokens(estimate_source)
93
+ if est > (@threshold * micro_compact_ratio) && MicroCompact.call(messages).to_i.positive?
94
+ refresh_conversation_estimate(conversation)
95
+ end
87
96
 
88
- return unless needs_compaction?(messages)
97
+ return unless needs_compaction?(estimate_source)
89
98
 
90
99
  # Step 2: Try context collapse (snip old messages, no LLM call)
91
100
  collapsed = ContextCollapse.call(messages, threshold: @threshold)
@@ -138,6 +147,14 @@ module RubynCode
138
147
  elsif conversation.respond_to?(:messages=)
139
148
  conversation.messages = new_messages
140
149
  end
150
+ refresh_conversation_estimate(conversation)
151
+ end
152
+
153
+ # MicroCompact and the compaction strategies mutate or replace messages
154
+ # outside the conversation's own append path, so its incremental token
155
+ # bookkeeping must be invalidated afterwards.
156
+ def refresh_conversation_estimate(conversation)
157
+ conversation.refresh_derived_state! if conversation.respond_to?(:refresh_derived_state!)
141
158
  end
142
159
  end
143
160
  end
@@ -68,7 +68,7 @@ module RubynCode
68
68
  ]
69
69
 
70
70
  options = {}
71
- options[:model] = 'claude-sonnet-4-6' if llm_client.respond_to?(:chat)
71
+ options[:model] = 'claude-sonnet-5' if llm_client.respond_to?(:chat)
72
72
 
73
73
  response = llm_client.chat(messages: summary_messages, **options)
74
74
 
@@ -1,11 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'pastel'
4
-
5
3
  module RubynCode
6
4
  module Debug
7
- PASTEL = Pastel.new
8
-
9
5
  @enabled = false
10
6
  @output = $stderr
11
7
 
@@ -32,7 +28,7 @@ module RubynCode
32
28
  return unless enabled?
33
29
 
34
30
  timestamp = Time.now.strftime('%H:%M:%S.%L')
35
- prefix = "#{PASTEL.dim("[#{timestamp}]")} #{PASTEL.send(color, "[#{tag}]")}"
31
+ prefix = "#{pastel.dim("[#{timestamp}]")} #{pastel.send(color, "[#{tag}]")}"
36
32
  @output.puts "#{prefix} #{message}"
37
33
  end
38
34
 
@@ -69,6 +65,16 @@ module RubynCode
69
65
  def error(message)
70
66
  log('error', message, color: :red)
71
67
  end
68
+
69
+ private
70
+
71
+ # Lazy so boot never pays for pastel when debug output is off.
72
+ def pastel
73
+ @pastel ||= begin
74
+ require 'pastel'
75
+ Pastel.new
76
+ end
77
+ end
72
78
  end
73
79
  end
74
80
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ # Session goals: a goal is a plain-language condition the user wants
5
+ # satisfied before the agent stops working. The Goal::Evaluator judges,
6
+ # via a lightweight LLM call, whether the condition has been met based on
7
+ # the recent conversation. Used by Hooks::GoalHook on the :stop event.
8
+ module Goal
9
+ # Judges whether a goal condition has been satisfied.
10
+ #
11
+ # The evaluator is deliberately conservative: it returns true only when
12
+ # the model is confident the goal is genuinely complete. Any error or
13
+ # ambiguous answer is treated as "not met" so the agent keeps working
14
+ # rather than stopping prematurely.
15
+ class Evaluator
16
+ SYSTEM_PROMPT = <<~PROMPT
17
+ You are a strict completion judge. Given a GOAL and a transcript of an
18
+ AI coding agent's recent work, decide whether the goal is genuinely and
19
+ fully satisfied. Be conservative: if there is any doubt, or the work is
20
+ only partially done, answer NO. Answer with exactly one word on the
21
+ first line: YES or NO. Optionally add a short reason on the next line.
22
+ PROMPT
23
+
24
+ # Number of trailing conversation messages to show the judge.
25
+ TRANSCRIPT_WINDOW = 12
26
+
27
+ # @param llm_client [LLM::Client]
28
+ def initialize(llm_client:)
29
+ @llm_client = llm_client
30
+ end
31
+
32
+ # @param condition [String] the goal condition
33
+ # @param conversation [Agent::Conversation, nil] recent work to judge
34
+ # @return [Boolean] true only when the goal is confidently complete
35
+ def call(condition:, conversation: nil)
36
+ response = @llm_client.chat(
37
+ messages: [{ role: 'user', content: prompt(condition, conversation) }],
38
+ system: SYSTEM_PROMPT
39
+ )
40
+ verdict_yes?(answer_text(response))
41
+ rescue StandardError => e
42
+ RubynCode::Debug.warn("Goal evaluation failed: #{e.message}")
43
+ false
44
+ end
45
+
46
+ private
47
+
48
+ def prompt(condition, conversation)
49
+ <<~TEXT
50
+ GOAL:
51
+ #{condition}
52
+
53
+ RECENT WORK:
54
+ #{transcript(conversation)}
55
+
56
+ Is the goal genuinely and fully satisfied? Answer YES or NO.
57
+ TEXT
58
+ end
59
+
60
+ def transcript(conversation)
61
+ return '(no work recorded yet)' unless conversation.respond_to?(:messages)
62
+
63
+ Array(conversation.messages).last(TRANSCRIPT_WINDOW).map do |msg|
64
+ "#{msg[:role]}: #{message_text(msg[:content])}"
65
+ end.join("\n").slice(0, 6000)
66
+ end
67
+
68
+ def message_text(content)
69
+ case content
70
+ when String then content
71
+ when Array
72
+ content.filter_map { |b| b.is_a?(Hash) ? (b[:text] || b['text']) : nil }.join(' ')
73
+ else
74
+ content.to_s
75
+ end
76
+ end
77
+
78
+ def answer_text(response)
79
+ if response.respond_to?(:content)
80
+ Array(response.content).filter_map { |b| b.respond_to?(:text) ? b.text : nil }.join("\n")
81
+ elsif response.is_a?(Hash)
82
+ Array(response[:content] || response['content'])
83
+ .filter_map { |b| b.is_a?(Hash) ? (b[:text] || b['text']) : nil }.join("\n")
84
+ else
85
+ response.to_s
86
+ end
87
+ end
88
+
89
+ def verdict_yes?(text)
90
+ first = text.to_s.strip.split("\n").first.to_s.strip.upcase
91
+ first.start_with?('YES')
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Hooks
5
+ # Maps between internal hook event names (snake_case symbols used by the
6
+ # in-process Hooks::Runner) and the Claude Code hook event names
7
+ # (CamelCase strings used by external hooks configured in settings.json).
8
+ #
9
+ # Internal hooks fire at these 7 sites:
10
+ # :pre_tool_use — agent/tool_processor.rb#execute_tool
11
+ # :post_tool_use — agent/tool_processor.rb#execute_tool
12
+ # :pre_llm_call — agent/llm_caller.rb
13
+ # :post_llm_call — agent/llm_caller.rb
14
+ # :stop — agent/loop.rb#stop_blocked?
15
+ # :session_start — ide/handlers/prompt_handler.rb (IDE)
16
+ # :user_prompt_submit — ide/handlers/prompt_handler.rb (IDE)
17
+ #
18
+ # External hooks (Claude Code parity) consume these 9 event names:
19
+ # PreToolUse, PostToolUse, UserPromptSubmit, SessionStart,
20
+ # SessionEnd, Stop, SubagentStop, PreCompact, Notification
21
+ module EventMap
22
+ # Internal symbol => external string
23
+ TO_EXTERNAL = {
24
+ pre_tool_use: 'PreToolUse',
25
+ post_tool_use: 'PostToolUse',
26
+ pre_llm_call: 'PreCompact',
27
+ post_llm_call: 'Notification',
28
+ on_session_end: 'SessionEnd',
29
+ session_start: 'SessionStart',
30
+ user_prompt_submit: 'UserPromptSubmit',
31
+ stop: 'Stop',
32
+ on_subagent_stop: 'SubagentStop'
33
+ }.freeze
34
+
35
+ # External string => internal symbol
36
+ TO_INTERNAL = TO_EXTERNAL.invert.freeze
37
+
38
+ # Every external event name the dispatcher knows about.
39
+ EXTERNAL_EVENTS = TO_EXTERNAL.values.freeze
40
+
41
+ module_function
42
+
43
+ # @param internal [Symbol]
44
+ # @return [String, nil]
45
+ def external(internal)
46
+ TO_EXTERNAL[internal.to_sym]
47
+ end
48
+
49
+ # @param external [String, Symbol]
50
+ # @return [Symbol, nil]
51
+ def internal(external)
52
+ TO_INTERNAL[external.to_s]
53
+ end
54
+ end
55
+ end
56
+ end