rubyn-code 0.5.1 → 0.7.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -3
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  17. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  18. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  19. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  20. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  21. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  23. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  24. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  25. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  26. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  27. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  28. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  29. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  30. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  31. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  32. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  33. data/lib/rubyn_code/cli/first_run.rb +1 -1
  34. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  35. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  36. data/lib/rubyn_code/cli/renderer.rb +3 -2
  37. data/lib/rubyn_code/cli/repl.rb +37 -14
  38. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  39. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  40. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  41. data/lib/rubyn_code/cli/version_check.rb +10 -3
  42. data/lib/rubyn_code/config/defaults.rb +13 -1
  43. data/lib/rubyn_code/config/schema.json +4 -0
  44. data/lib/rubyn_code/config/settings.rb +17 -2
  45. data/lib/rubyn_code/context/manager.rb +29 -12
  46. data/lib/rubyn_code/debug.rb +11 -5
  47. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  48. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  49. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  50. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  51. data/lib/rubyn_code/hooks/response.rb +83 -0
  52. data/lib/rubyn_code/hooks/runner.rb +61 -3
  53. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  54. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  55. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  56. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  57. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  58. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  59. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  60. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  61. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  62. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  63. data/lib/rubyn_code/learning/porter.rb +129 -0
  64. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  65. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  66. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  67. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  68. data/lib/rubyn_code/llm/model_router.rb +2 -2
  69. data/lib/rubyn_code/mcp/client.rb +59 -0
  70. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  71. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  72. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  73. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  74. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  75. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  76. data/lib/rubyn_code/memory/search.rb +9 -5
  77. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  78. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  79. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  80. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  81. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  82. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  83. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  84. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  85. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  86. data/lib/rubyn_code/teams/manager.rb +83 -5
  87. data/lib/rubyn_code/teams/teammate.rb +5 -1
  88. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  89. data/lib/rubyn_code/tools/executor.rb +5 -3
  90. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  91. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  92. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  93. data/lib/rubyn_code/tools/web_search.rb +4 -1
  94. data/lib/rubyn_code/version.rb +1 -1
  95. data/lib/rubyn_code.rb +45 -2
  96. data/skills/rubyn_self_test.md +322 -14
  97. data/skills/self_test/chisel_smoke.rb +84 -0
  98. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  99. metadata +37 -1
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ # Expands `@path/to/file` mentions in user input into inline file content,
6
+ # mirroring Claude Code / Codex `@`-mentions. The original text is kept and
7
+ # the referenced files are appended as labeled, fenced blocks so the agent
8
+ # sees both the request and the files it points at.
9
+ #
10
+ # Conservative by design: only existing, readable, reasonably-sized regular
11
+ # files inside the project are expanded. Unresolved mentions (and things
12
+ # that merely look like mentions, e.g. email addresses) are left untouched.
13
+ class MentionExpander
14
+ # A mention is an "@" that doesn't follow a word char or another "@"
15
+ # (so emails like foo@bar.com don't match), followed by a path-ish run.
16
+ MENTION = /(?<![\w@])@([^\s@]+)/
17
+ TRAILING_PUNCT = /[).,;:!?'"]+\z/
18
+ MAX_FILE_BYTES = 64 * 1024
19
+ MAX_FILES = 10
20
+
21
+ # @param project_root [String]
22
+ def initialize(project_root:)
23
+ @project_root = project_root
24
+ end
25
+
26
+ # @param input [String] raw user input
27
+ # @return [Array(String, Array<String>)] expanded text + resolved rel paths
28
+ def expand(input)
29
+ return [input, []] unless input.is_a?(String) && input.include?('@')
30
+
31
+ resolved = scan(input)
32
+ return [input, []] if resolved.empty?
33
+
34
+ blocks = resolved.map { |rel, abs| file_block(rel, abs) }
35
+ ["#{input}\n\n#{blocks.join("\n\n")}", resolved.map(&:first)]
36
+ end
37
+
38
+ private
39
+
40
+ # @return [Array<Array(String, String)>] unique [rel, abs] pairs, in order
41
+ def scan(input)
42
+ seen = {}
43
+ input.scan(MENTION).each do |(raw)|
44
+ rel = raw.sub(TRAILING_PUNCT, '')
45
+ next if rel.empty?
46
+
47
+ abs = resolve(rel)
48
+ next unless abs && !seen.key?(abs)
49
+
50
+ seen[abs] = rel
51
+ break if seen.size >= MAX_FILES
52
+ end
53
+ seen.map { |abs, rel| [rel, abs] }
54
+ end
55
+
56
+ # Resolve a mention to an absolute path inside the project, or nil.
57
+ def resolve(rel)
58
+ abs = File.expand_path(rel, @project_root)
59
+ return nil unless inside_project?(abs)
60
+ return nil unless File.file?(abs)
61
+
62
+ abs
63
+ rescue StandardError
64
+ nil
65
+ end
66
+
67
+ def inside_project?(abs)
68
+ root = File.expand_path(@project_root)
69
+ abs == root || abs.start_with?("#{root}#{File::SEPARATOR}")
70
+ end
71
+
72
+ def file_block(rel, abs)
73
+ body = read_truncated(abs)
74
+ <<~BLOCK.strip
75
+ @#{rel}:
76
+ ```
77
+ #{body}
78
+ ```
79
+ BLOCK
80
+ end
81
+
82
+ def read_truncated(abs)
83
+ content = File.read(abs, MAX_FILE_BYTES + 1)
84
+ return content if content.bytesize <= MAX_FILE_BYTES
85
+
86
+ "#{content.byteslice(0, MAX_FILE_BYTES)}\n… [truncated]"
87
+ rescue StandardError => e
88
+ "[could not read file: #{e.message}]"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -1,14 +1,12 @@
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
8
7
  class Renderer
9
8
  def initialize
10
9
  @pastel = Pastel.new
11
- @rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
12
10
  end
13
11
 
14
12
  attr_writer :yolo
@@ -177,6 +175,9 @@ module RubynCode
177
175
  end
178
176
 
179
177
  def render_code_block(code, lang)
178
+ # Lazy so REPL boot never pays for rouge until a code block is rendered.
179
+ require 'rouge'
180
+ @rouge_formatter ||= Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
180
181
  lexer = Rouge::Lexer.find(lang) || Rouge::Lexers::PlainText.new
181
182
  highlighted = @rouge_formatter.format(lexer.lex(code))
182
183
  border = @pastel.dim(' │ ')
@@ -31,10 +31,10 @@ module RubynCode
31
31
  def run
32
32
  @version_check = VersionCheck.new(renderer: @renderer)
33
33
  @version_check.start
34
+ @auto_suggest = Skills::AutoSuggest.new(project_root: @project_root)
35
+ @auto_suggest.start
34
36
 
35
37
  @renderer.welcome
36
- @version_check.notify
37
- check_skill_suggestions!
38
38
 
39
39
  at_exit { shutdown! }
40
40
 
@@ -45,17 +45,20 @@ module RubynCode
45
45
 
46
46
  private
47
47
 
48
- def check_skill_suggestions!
49
- suggest = Skills::AutoSuggest.new(project_root: @project_root)
50
- message = suggest.check
48
+ # Surface results of the background startup checks (version, skill
49
+ # suggestions) between prompts — never blocking input on the network.
50
+ def flush_startup_notices!
51
+ @version_check&.notify
52
+ message = @auto_suggest&.pending_message
51
53
  @renderer.info(message) if message
52
54
  rescue StandardError
53
- # Never block session start on suggestion failure
55
+ # Never block the prompt on startup notices
54
56
  end
55
57
 
56
58
  def run_input_loop
57
59
  while @running
58
60
  begin
61
+ flush_startup_notices!
59
62
  input = read_input
60
63
  break if input.nil?
61
64
 
@@ -109,26 +112,26 @@ module RubynCode
109
112
 
110
113
  # -- sequential steps with interrupt rescue
111
114
  def handle_message(input)
115
+ # Checkpoint before the turn (raw input as label), then expand
116
+ # @-mentions so the agent sees referenced file contents.
117
+ @checkpoint_manager&.checkpoint!(label: input, conversation: @conversation)
118
+ input = expand_mentions(input)
112
119
  @spinner.start
113
120
  @streaming_first_chunk = true
114
121
 
115
122
  response = @agent_loop.send_message(input)
116
123
 
117
124
  @spinner.stop
118
- if @streaming_first_chunk
119
- @renderer.display(response)
120
- else
121
- @stream_formatter&.flush
122
- @stream_formatter = nil
123
- puts
124
- end
125
-
125
+ render_response(response)
126
126
  save_session!
127
+ response
127
128
  rescue Interrupt
128
129
  @spinner.stop
129
130
  puts
130
131
  @renderer.warning('Interrupted — session state preserved')
132
+ @current_loop&.stop! # Ctrl-C during a /loop iteration stops the loop
131
133
  save_session!
134
+ nil
132
135
  rescue BudgetExceededError => e
133
136
  @spinner.error
134
137
  @renderer.error("Budget exceeded: #{e.message}")
@@ -137,6 +140,26 @@ module RubynCode
137
140
  @renderer.error("Error: #{e.message}")
138
141
  end
139
142
 
143
+ def render_response(response)
144
+ if @streaming_first_chunk
145
+ @renderer.display(response)
146
+ else
147
+ @stream_formatter&.flush
148
+ @stream_formatter = nil
149
+ puts
150
+ end
151
+ end
152
+
153
+ # Expand @path file mentions into inline content before the agent sees
154
+ # the message. Surfaces what was attached so the user knows.
155
+ def expand_mentions(input)
156
+ expanded, paths = @mention_expander.expand(input)
157
+ @renderer.info("📎 Attached: #{paths.join(', ')}") unless paths.empty?
158
+ expanded
159
+ rescue StandardError
160
+ input
161
+ end
162
+
140
163
  def setup_readline!
141
164
  completions = @command_registry.completions
142
165
 
@@ -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
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
@@ -33,7 +33,11 @@ module RubynCode
33
33
  @tool_executor = Tools::Executor.new(project_root: @project_root)
34
34
  @context_manager = Context::Manager.new(llm_client: @llm_client)
35
35
  @hook_registry = Hooks::Registry.new
36
- @hook_runner = Hooks::Runner.new(registry: @hook_registry)
36
+ @external_hook_dispatcher = Hooks::ExternalDispatcher.new(project_root: @project_root)
37
+ @hook_runner = Hooks::Runner.new(
38
+ registry: @hook_registry,
39
+ external_dispatcher: @external_hook_dispatcher
40
+ )
37
41
  @stall_detector = Agent::LoopDetector.new
38
42
  end
39
43
 
@@ -45,6 +49,8 @@ module RubynCode
45
49
  @skill_matcher = build_skill_matcher
46
50
  @web_skill_autoload = build_web_skill_autoload
47
51
  @session_persistence = Memory::SessionPersistence.new(@db)
52
+ @mention_expander = MentionExpander.new(project_root: @project_root)
53
+ @checkpoint_manager = Checkpoint::Manager.new(project_root: @project_root)
48
54
  end
49
55
 
50
56
  def build_skill_matcher
@@ -127,6 +133,8 @@ module RubynCode
127
133
  def setup_hooks!
128
134
  Hooks::BuiltIn.register_all!(@hook_registry)
129
135
  Hooks::UserHooks.load!(@hook_registry, project_root: @project_root)
136
+ # Snapshot files before they are mutated so /rewind can restore them.
137
+ @hook_registry.on(:pre_tool_use, Checkpoint::Hook.new(manager: @checkpoint_manager), priority: 5)
130
138
  end
131
139
 
132
140
  def setup_agent_loop!
@@ -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-4-8'
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,16 @@ 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
+
36
45
  SESSION_BUDGET_USD = 5.00
37
46
  DAILY_BUDGET_USD = 10.00
38
47
 
48
+ # Claude Code's credentials file (Linux/other — no system keychain)
49
+ CLAUDE_CREDENTIALS_FILE = File.expand_path('~/.claude/.credentials.json')
50
+
39
51
  OAUTH_CLIENT_ID = 'rubyn-code'
40
52
  OAUTH_REDIRECT_URI = 'http://localhost:19275/callback'
41
53
  OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
@@ -43,6 +43,10 @@
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"]
46
50
  }
47
51
  },
48
52
  "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,7 @@ 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
20
22
  ].freeze
21
23
 
22
24
  DEFAULT_MAP = {
@@ -37,12 +39,19 @@ module RubynCode
37
39
  oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
38
40
  oauth_token_url: Defaults::OAUTH_TOKEN_URL,
39
41
  oauth_scopes: Defaults::OAUTH_SCOPES,
40
- skills_autoload: Defaults::SKILLS_AUTOLOAD
42
+ skills_autoload: Defaults::SKILLS_AUTOLOAD,
43
+ chisel_mode: Defaults::CHISEL_MODE
41
44
  }.freeze
42
45
 
43
46
  attr_reader :config_path, :data
44
47
 
45
48
  def initialize(config_path: Defaults::CONFIG_FILE)
49
+ # When tests run, isolate Settings from the developer's personal
50
+ # ~/.rubyn-code/config.yml so a stray `provider: minimax` can't
51
+ # shadow the test expectations. The test config lives in
52
+ # tmpdir, is process-pid-scoped, and is harmless if it leaks.
53
+ config_path = self.class.test_config_path if config_path == Defaults::CONFIG_FILE && ENV['RUBYN_TESTING']
54
+
46
55
  @config_path = config_path
47
56
  @data = {}
48
57
  ensure_home_directory!
@@ -51,6 +60,12 @@ module RubynCode
51
60
  backfill_provider_models!
52
61
  end
53
62
 
63
+ # @return [String] a per-pid path under tmpdir used when
64
+ # RUBYN_TESTING is set
65
+ def self.test_config_path
66
+ @test_config_path ||= File.join(Dir.tmpdir, "rubyn-test-config-#{Process.pid}.yml")
67
+ end
68
+
54
69
  # Define accessor methods for each configurable key
55
70
  CONFIGURABLE_KEYS.each do |key|
56
71
  define_method(key) do
@@ -141,7 +156,7 @@ module RubynCode
141
156
  DEFAULT_PROVIDER_MODELS = {
142
157
  'anthropic' => {
143
158
  'env_key' => 'ANTHROPIC_API_KEY',
144
- 'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-6' }
159
+ 'models' => { 'cheap' => 'claude-haiku-4-5', 'mid' => 'claude-sonnet-4-6', 'top' => 'claude-opus-4-8' }
145
160
  },
146
161
  'openai' => {
147
162
  'env_key' => 'OPENAI_API_KEY',
@@ -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
@@ -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