rubyn-code 0.1.0 → 0.2.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -467
  3. data/db/migrations/009_create_teams.sql +6 -6
  4. data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
  5. data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
  6. data/exe/rubyn-code +1 -1
  7. data/lib/rubyn_code/agent/RUBYN.md +17 -0
  8. data/lib/rubyn_code/agent/conversation.rb +68 -19
  9. data/lib/rubyn_code/agent/loop.rb +312 -54
  10. data/lib/rubyn_code/agent/loop_detector.rb +6 -6
  11. data/lib/rubyn_code/auth/RUBYN.md +19 -0
  12. data/lib/rubyn_code/auth/oauth.rb +40 -35
  13. data/lib/rubyn_code/auth/server.rb +16 -12
  14. data/lib/rubyn_code/auth/token_store.rb +22 -22
  15. data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
  16. data/lib/rubyn_code/autonomous/daemon.rb +115 -79
  17. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
  18. data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
  19. data/lib/rubyn_code/background/RUBYN.md +13 -0
  20. data/lib/rubyn_code/background/notifier.rb +0 -2
  21. data/lib/rubyn_code/background/worker.rb +60 -15
  22. data/lib/rubyn_code/cli/RUBYN.md +30 -0
  23. data/lib/rubyn_code/cli/app.rb +85 -9
  24. data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
  25. data/lib/rubyn_code/cli/commands/base.rb +53 -0
  26. data/lib/rubyn_code/cli/commands/budget.rb +24 -0
  27. data/lib/rubyn_code/cli/commands/clear.rb +16 -0
  28. data/lib/rubyn_code/cli/commands/compact.rb +21 -0
  29. data/lib/rubyn_code/cli/commands/context.rb +44 -0
  30. data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
  31. data/lib/rubyn_code/cli/commands/cost.rb +23 -0
  32. data/lib/rubyn_code/cli/commands/diff.rb +30 -0
  33. data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
  34. data/lib/rubyn_code/cli/commands/help.rb +41 -0
  35. data/lib/rubyn_code/cli/commands/model.rb +37 -0
  36. data/lib/rubyn_code/cli/commands/plan.rb +22 -0
  37. data/lib/rubyn_code/cli/commands/quit.rb +17 -0
  38. data/lib/rubyn_code/cli/commands/registry.rb +64 -0
  39. data/lib/rubyn_code/cli/commands/resume.rb +51 -0
  40. data/lib/rubyn_code/cli/commands/review.rb +26 -0
  41. data/lib/rubyn_code/cli/commands/skill.rb +32 -0
  42. data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
  43. data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
  44. data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
  45. data/lib/rubyn_code/cli/commands/undo.rb +17 -0
  46. data/lib/rubyn_code/cli/commands/version.rb +16 -0
  47. data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
  48. data/lib/rubyn_code/cli/input_handler.rb +20 -23
  49. data/lib/rubyn_code/cli/renderer.rb +25 -27
  50. data/lib/rubyn_code/cli/repl.rb +161 -194
  51. data/lib/rubyn_code/cli/setup.rb +117 -0
  52. data/lib/rubyn_code/cli/spinner.rb +40 -40
  53. data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
  54. data/lib/rubyn_code/cli/version_check.rb +94 -0
  55. data/lib/rubyn_code/config/RUBYN.md +14 -0
  56. data/lib/rubyn_code/config/defaults.rb +28 -19
  57. data/lib/rubyn_code/config/project_config.rb +7 -9
  58. data/lib/rubyn_code/config/settings.rb +3 -3
  59. data/lib/rubyn_code/context/RUBYN.md +20 -0
  60. data/lib/rubyn_code/context/auto_compact.rb +7 -7
  61. data/lib/rubyn_code/context/compactor.rb +2 -2
  62. data/lib/rubyn_code/context/context_collapse.rb +45 -0
  63. data/lib/rubyn_code/context/manager.rb +20 -3
  64. data/lib/rubyn_code/context/manual_compact.rb +7 -7
  65. data/lib/rubyn_code/context/micro_compact.rb +12 -12
  66. data/lib/rubyn_code/db/RUBYN.md +40 -0
  67. data/lib/rubyn_code/db/connection.rb +13 -13
  68. data/lib/rubyn_code/db/migrator.rb +67 -27
  69. data/lib/rubyn_code/db/schema.rb +6 -6
  70. data/lib/rubyn_code/debug.rb +74 -0
  71. data/lib/rubyn_code/hooks/RUBYN.md +17 -0
  72. data/lib/rubyn_code/hooks/built_in.rb +9 -9
  73. data/lib/rubyn_code/hooks/registry.rb +5 -5
  74. data/lib/rubyn_code/hooks/runner.rb +1 -1
  75. data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
  76. data/lib/rubyn_code/learning/RUBYN.md +16 -0
  77. data/lib/rubyn_code/learning/extractor.rb +22 -22
  78. data/lib/rubyn_code/learning/injector.rb +17 -18
  79. data/lib/rubyn_code/learning/instinct.rb +18 -14
  80. data/lib/rubyn_code/llm/RUBYN.md +15 -0
  81. data/lib/rubyn_code/llm/client.rb +121 -55
  82. data/lib/rubyn_code/llm/message_builder.rb +19 -15
  83. data/lib/rubyn_code/llm/streaming.rb +80 -50
  84. data/lib/rubyn_code/mcp/RUBYN.md +21 -0
  85. data/lib/rubyn_code/mcp/client.rb +25 -24
  86. data/lib/rubyn_code/mcp/config.rb +7 -7
  87. data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
  88. data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
  90. data/lib/rubyn_code/memory/RUBYN.md +17 -0
  91. data/lib/rubyn_code/memory/models.rb +3 -3
  92. data/lib/rubyn_code/memory/search.rb +17 -17
  93. data/lib/rubyn_code/memory/session_persistence.rb +49 -34
  94. data/lib/rubyn_code/memory/store.rb +17 -17
  95. data/lib/rubyn_code/observability/RUBYN.md +19 -0
  96. data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
  97. data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
  98. data/lib/rubyn_code/observability/token_counter.rb +1 -1
  99. data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
  100. data/lib/rubyn_code/output/RUBYN.md +11 -0
  101. data/lib/rubyn_code/output/diff_renderer.rb +6 -6
  102. data/lib/rubyn_code/output/formatter.rb +4 -4
  103. data/lib/rubyn_code/permissions/RUBYN.md +17 -0
  104. data/lib/rubyn_code/permissions/prompter.rb +8 -8
  105. data/lib/rubyn_code/protocols/RUBYN.md +14 -0
  106. data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
  107. data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
  108. data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
  109. data/lib/rubyn_code/skills/RUBYN.md +19 -0
  110. data/lib/rubyn_code/skills/catalog.rb +7 -7
  111. data/lib/rubyn_code/skills/document.rb +15 -15
  112. data/lib/rubyn_code/skills/loader.rb +6 -8
  113. data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
  114. data/lib/rubyn_code/sub_agents/runner.rb +15 -15
  115. data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
  116. data/lib/rubyn_code/tasks/RUBYN.md +13 -0
  117. data/lib/rubyn_code/tasks/dag.rb +12 -16
  118. data/lib/rubyn_code/tasks/manager.rb +24 -24
  119. data/lib/rubyn_code/tasks/models.rb +4 -4
  120. data/lib/rubyn_code/teams/RUBYN.md +14 -0
  121. data/lib/rubyn_code/teams/mailbox.rb +38 -18
  122. data/lib/rubyn_code/teams/manager.rb +19 -19
  123. data/lib/rubyn_code/teams/teammate.rb +3 -4
  124. data/lib/rubyn_code/tools/RUBYN.md +38 -0
  125. data/lib/rubyn_code/tools/background_run.rb +9 -11
  126. data/lib/rubyn_code/tools/base.rb +54 -3
  127. data/lib/rubyn_code/tools/bash.rb +16 -34
  128. data/lib/rubyn_code/tools/bundle_add.rb +10 -12
  129. data/lib/rubyn_code/tools/bundle_install.rb +9 -11
  130. data/lib/rubyn_code/tools/compact.rb +10 -9
  131. data/lib/rubyn_code/tools/db_migrate.rb +17 -15
  132. data/lib/rubyn_code/tools/edit_file.rb +12 -12
  133. data/lib/rubyn_code/tools/executor.rb +9 -4
  134. data/lib/rubyn_code/tools/git_commit.rb +29 -34
  135. data/lib/rubyn_code/tools/git_diff.rb +17 -18
  136. data/lib/rubyn_code/tools/git_log.rb +17 -19
  137. data/lib/rubyn_code/tools/git_status.rb +18 -20
  138. data/lib/rubyn_code/tools/glob.rb +7 -9
  139. data/lib/rubyn_code/tools/grep.rb +11 -9
  140. data/lib/rubyn_code/tools/load_skill.rb +7 -7
  141. data/lib/rubyn_code/tools/memory_search.rb +13 -12
  142. data/lib/rubyn_code/tools/memory_write.rb +14 -12
  143. data/lib/rubyn_code/tools/rails_generate.rb +16 -16
  144. data/lib/rubyn_code/tools/read_file.rb +8 -7
  145. data/lib/rubyn_code/tools/read_inbox.rb +5 -5
  146. data/lib/rubyn_code/tools/registry.rb +2 -2
  147. data/lib/rubyn_code/tools/review_pr.rb +55 -55
  148. data/lib/rubyn_code/tools/run_specs.rb +20 -19
  149. data/lib/rubyn_code/tools/schema.rb +9 -11
  150. data/lib/rubyn_code/tools/send_message.rb +10 -10
  151. data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
  152. data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
  153. data/lib/rubyn_code/tools/task.rb +28 -28
  154. data/lib/rubyn_code/tools/web_fetch.rb +46 -31
  155. data/lib/rubyn_code/tools/web_search.rb +64 -66
  156. data/lib/rubyn_code/tools/write_file.rb +7 -6
  157. data/lib/rubyn_code/version.rb +1 -1
  158. data/lib/rubyn_code.rb +136 -105
  159. metadata +94 -21
@@ -1,50 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-spinner"
3
+ require 'tty-spinner'
4
4
 
5
5
  module RubynCode
6
6
  module CLI
7
7
  class Spinner
8
8
  THINKING_MESSAGES = [
9
- "Massaging the hash...",
10
- "Refactoring in my head...",
11
- "Consulting Matz...",
12
- "Freezing strings...",
13
- "Monkey-patching reality...",
14
- "Yielding to the block...",
15
- "Enumerating possibilities...",
16
- "Injecting dependencies...",
17
- "Guard clause-ing my thoughts...",
18
- "Sharpening the gems...",
19
- "Duck typing furiously...",
20
- "Reducing complexity...",
21
- "Mapping it out...",
22
- "Selecting the right approach...",
23
- "Running the mental specs...",
24
- "Composing a module...",
25
- "Memoizing the answer...",
26
- "Digging through the hash...",
27
- "Pattern matching on this...",
28
- "Raising my standards...",
29
- "Rescuing the situation...",
30
- "Benchmarking my thoughts...",
31
- "Sending :think to self...",
32
- "Evaluating the proc...",
33
- "Opening the eigenclass...",
34
- "Calling .new on an idea...",
35
- "Plucking the good bits...",
36
- "Finding each solution...",
37
- "Requiring more context...",
38
- "Bundling my thoughts...",
9
+ 'Massaging the hash...',
10
+ 'Refactoring in my head...',
11
+ 'Consulting Matz...',
12
+ 'Freezing strings...',
13
+ 'Monkey-patching reality...',
14
+ 'Yielding to the block...',
15
+ 'Enumerating possibilities...',
16
+ 'Injecting dependencies...',
17
+ 'Guard clause-ing my thoughts...',
18
+ 'Sharpening the gems...',
19
+ 'Duck typing furiously...',
20
+ 'Reducing complexity...',
21
+ 'Mapping it out...',
22
+ 'Selecting the right approach...',
23
+ 'Running the mental specs...',
24
+ 'Composing a module...',
25
+ 'Memoizing the answer...',
26
+ 'Digging through the hash...',
27
+ 'Pattern matching on this...',
28
+ 'Raising my standards...',
29
+ 'Rescuing the situation...',
30
+ 'Benchmarking my thoughts...',
31
+ 'Sending :think to self...',
32
+ 'Evaluating the proc...',
33
+ 'Opening the eigenclass...',
34
+ 'Calling .new on an idea...',
35
+ 'Plucking the good bits...',
36
+ 'Finding each solution...',
37
+ 'Requiring more context...',
38
+ 'Bundling my thoughts...'
39
39
  ].freeze
40
40
 
41
41
  SUB_AGENT_MESSAGES = [
42
- "Sub-agent is spelunking...",
43
- "Agent exploring the codebase...",
44
- "Reading all the things...",
45
- "Sub-agent doing the legwork...",
46
- "Agent grepping through files...",
47
- "Dispatching the intern...",
42
+ 'Sub-agent is spelunking...',
43
+ 'Agent exploring the codebase...',
44
+ 'Reading all the things...',
45
+ 'Sub-agent doing the legwork...',
46
+ 'Agent grepping through files...',
47
+ 'Dispatching the intern...'
48
48
  ].freeze
49
49
 
50
50
  def initialize
@@ -62,7 +62,7 @@ module RubynCode
62
62
  end
63
63
 
64
64
  def start_sub_agent(tool_count = 0)
65
- msg = if tool_count > 0
65
+ msg = if tool_count.positive?
66
66
  "#{SUB_AGENT_MESSAGES.sample} (#{tool_count} tools)"
67
67
  else
68
68
  SUB_AGENT_MESSAGES.sample
@@ -77,12 +77,12 @@ module RubynCode
77
77
  start(message)
78
78
  end
79
79
 
80
- def success(message = "Done")
80
+ def success(message = 'Done')
81
81
  @spinner&.success("(#{message})")
82
82
  @spinner = nil
83
83
  end
84
84
 
85
- def error(message = "Failed")
85
+ def error(message = 'Failed')
86
86
  @spinner&.error("(#{message})")
87
87
  @spinner = nil
88
88
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pastel"
4
- require "rouge"
3
+ require 'pastel'
4
+ require 'rouge'
5
5
 
6
6
  module RubynCode
7
7
  module CLI
@@ -9,13 +9,13 @@ module RubynCode
9
9
  # Buffers code blocks until they close, then syntax-highlights them.
10
10
  # Applies inline formatting (bold, code, headers) as text arrives.
11
11
  class StreamFormatter
12
- def initialize(renderer = nil)
12
+ def initialize(_renderer = nil)
13
13
  @pastel = Pastel.new
14
14
  @rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
15
- @buffer = +""
15
+ @buffer = +''
16
16
  @in_code_block = false
17
17
  @code_lang = nil
18
- @code_buffer = +""
18
+ @code_buffer = +''
19
19
  end
20
20
 
21
21
  # Feed a chunk of streamed text
@@ -29,11 +29,11 @@ module RubynCode
29
29
  end
30
30
 
31
31
  # Print any remaining partial line (no newline yet) if not in a code block
32
- unless @in_code_block || @buffer.empty?
33
- $stdout.print format_inline(@buffer)
34
- $stdout.flush
35
- @buffer = +""
36
- end
32
+ return if @in_code_block || @buffer.empty?
33
+
34
+ $stdout.print format_inline(@buffer)
35
+ $stdout.flush
36
+ @buffer = +''
37
37
  end
38
38
 
39
39
  # Flush any remaining buffered content
@@ -45,7 +45,7 @@ module RubynCode
45
45
  else
46
46
  $stdout.print format_inline(@buffer)
47
47
  end
48
- @buffer = +""
48
+ @buffer = +''
49
49
  end
50
50
 
51
51
  # Flush unclosed code block
@@ -69,8 +69,8 @@ module RubynCode
69
69
  # Opening fence
70
70
  @in_code_block = true
71
71
  @code_lang = stripped.match(/```(\w*)/)[1]
72
- @code_lang = "ruby" if @code_lang.empty?
73
- @code_buffer = +""
72
+ @code_lang = 'ruby' if @code_lang.empty?
73
+ @code_buffer = +''
74
74
  $stdout.puts @pastel.dim(" ┌─ #{@code_lang}")
75
75
  end
76
76
  return
@@ -89,50 +89,51 @@ module RubynCode
89
89
  def render_code_block
90
90
  return if @code_buffer.empty?
91
91
 
92
- lexer = Rouge::Lexer.find(@code_lang || "ruby") || Rouge::Lexers::PlainText.new
92
+ lexer = Rouge::Lexer.find(@code_lang || 'ruby') || Rouge::Lexers::PlainText.new
93
93
  highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
94
- border = @pastel.dim("")
94
+ border = @pastel.dim('')
95
95
 
96
96
  highlighted.each_line do |l|
97
97
  $stdout.print "#{border}#{l}"
98
98
  end
99
- $stdout.puts @pastel.dim(" └─")
99
+ $stdout.puts @pastel.dim(' └─')
100
100
  $stdout.flush
101
101
 
102
- @code_buffer = +""
102
+ @code_buffer = +''
103
103
  rescue StandardError
104
104
  # Fallback: print unformatted
105
105
  @code_buffer.each_line { |l| $stdout.print " #{l}" }
106
106
  $stdout.puts
107
- @code_buffer = +""
107
+ @code_buffer = +''
108
108
  end
109
109
 
110
110
  def format_line(line)
111
111
  stripped = line.rstrip
112
112
 
113
113
  # Headers
114
- if stripped.match?(/\A\#{1,6}\s/)
114
+ case stripped
115
+ when /\A\#{1,6}\s/
115
116
  level = stripped.match(/\A(\#{1,6})\s/)[1].length
116
- text = stripped.sub(/\A\#{1,6}\s+/, "")
117
+ text = stripped.sub(/\A\#{1,6}\s+/, '')
117
118
  case level
118
119
  when 1 then "#{@pastel.bold.underline(text)}\n"
119
120
  when 2 then "\n#{@pastel.bold(text)}\n"
120
121
  else "#{@pastel.bold(text)}\n"
121
122
  end
122
123
  # Bullet lists
123
- elsif stripped.match?(/\A\s*[-*]\s/)
124
+ when /\A\s*[-*]\s/
124
125
  indent = stripped.match(/\A(\s*)/)[1]
125
- content = stripped.sub(/\A\s*[-*]\s+/, "")
126
- "#{indent} #{@pastel.cyan("")} #{format_inline(content)}\n"
126
+ content = stripped.sub(/\A\s*[-*]\s+/, '')
127
+ "#{indent} #{@pastel.cyan('')} #{format_inline(content)}\n"
127
128
  # Numbered lists
128
- elsif stripped.match?(/\A\s*\d+\.\s/)
129
+ when /\A\s*\d+\.\s/
129
130
  indent = stripped.match(/\A(\s*)/)[1]
130
131
  num = stripped.match(/(\d+)\./)[1]
131
- content = stripped.sub(/\A\s*\d+\.\s+/, "")
132
- "#{indent} #{@pastel.cyan(num + ".")} #{format_inline(content)}\n"
132
+ content = stripped.sub(/\A\s*\d+\.\s+/, '')
133
+ "#{indent} #{@pastel.cyan("#{num}.")} #{format_inline(content)}\n"
133
134
  # Horizontal rules
134
- elsif stripped.match?(/\A-{3,}\z/)
135
- "#{@pastel.dim("" * 40)}\n"
135
+ when /\A-{3,}\z/
136
+ "#{@pastel.dim('' * 40)}\n"
136
137
  else
137
138
  "#{format_inline(line.chomp)}\n"
138
139
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module RubynCode
7
+ module CLI
8
+ # Non-blocking version check against RubyGems.
9
+ # Runs in a background thread so it never delays startup.
10
+ # Caches the result for 24 hours to avoid hammering the API.
11
+ class VersionCheck
12
+ RUBYGEMS_API = 'https://rubygems.org/api/v1/versions/rubyn-code/latest.json'
13
+ CACHE_FILE = File.join(Config::Defaults::HOME_DIR, '.version_check')
14
+ CACHE_TTL = 86_400 # 24 hours
15
+
16
+ def initialize(renderer:)
17
+ @renderer = renderer
18
+ @thread = nil
19
+ end
20
+
21
+ # Kicks off a background check. Call `notify` later to display results.
22
+ def start
23
+ return if ENV['RUBYN_NO_UPDATE_CHECK']
24
+
25
+ @thread = Thread.new { check }
26
+ @thread.abort_on_exception = false
27
+ end
28
+
29
+ # Waits briefly for the check to finish and prints a message if outdated.
30
+ def notify(timeout: 2)
31
+ return unless @thread
32
+
33
+ @thread.join(timeout)
34
+ return unless @result
35
+
36
+ return unless newer?(@result, RubynCode::VERSION)
37
+
38
+ @renderer.warning(
39
+ "Update available: #{RubynCode::VERSION} -> #{@result} " \
40
+ '(gem install rubyn-code)'
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def check
47
+ cached = read_cache
48
+ if cached
49
+ @result = cached
50
+ return
51
+ end
52
+
53
+ conn = Faraday.new do |f|
54
+ f.options.timeout = 5
55
+ f.options.open_timeout = 3
56
+ end
57
+ response = conn.get(RUBYGEMS_API)
58
+ return unless response.success?
59
+
60
+ data = JSON.parse(response.body)
61
+ latest = data['version']
62
+ return unless latest
63
+ return unless latest.match?(/\A\d+\.\d+/)
64
+ return unless Gem::Version.correct?(latest)
65
+
66
+ write_cache(latest)
67
+ @result = latest
68
+ rescue StandardError
69
+ # Silent — never interrupt startup for a version check
70
+ end
71
+
72
+ def newer?(remote, local)
73
+ Gem::Version.new(remote) > Gem::Version.new(local)
74
+ rescue ArgumentError
75
+ false
76
+ end
77
+
78
+ def read_cache
79
+ return nil unless File.exist?(CACHE_FILE)
80
+ return nil if (Time.now - File.mtime(CACHE_FILE)) > CACHE_TTL
81
+
82
+ File.read(CACHE_FILE).strip
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ def write_cache(version)
88
+ File.write(CACHE_FILE, version)
89
+ rescue StandardError
90
+ # Best effort
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,14 @@
1
+ # Config Layer
2
+
3
+ Application settings with per-project overrides.
4
+
5
+ ## Classes
6
+
7
+ - **`Settings`** — Main configuration module. Merges defaults with user overrides from
8
+ `~/.rubyn-code/config.yml` and project-level `.rubyn-code.yml`.
9
+
10
+ - **`Defaults`** — Frozen constants for all default values: `MAX_ITERATIONS`, model names,
11
+ token limits, budget caps, permission tiers, etc.
12
+
13
+ - **`ProjectConfig`** — Loads project-specific configuration from `.rubyn-code.yml` in the
14
+ working directory. Supports custom permission tiers, deny lists, and hook definitions.
@@ -3,19 +3,28 @@
3
3
  module RubynCode
4
4
  module Config
5
5
  module Defaults
6
- HOME_DIR = File.expand_path("~/.rubyn-code")
7
- CONFIG_FILE = File.join(HOME_DIR, "config.yml")
8
- DB_FILE = File.join(HOME_DIR, "rubyn_code.db")
9
- TOKENS_FILE = File.join(HOME_DIR, "tokens.yml")
10
- SESSIONS_DIR = File.join(HOME_DIR, "sessions")
11
- MEMORIES_DIR = File.join(HOME_DIR, "memories")
12
-
13
- DEFAULT_MODEL = "claude-opus-4-6"
6
+ HOME_DIR = File.expand_path('~/.rubyn-code')
7
+ CONFIG_FILE = File.join(HOME_DIR, 'config.yml')
8
+ DB_FILE = File.join(HOME_DIR, 'rubyn_code.db')
9
+ TOKENS_FILE = File.join(HOME_DIR, 'tokens.yml')
10
+ SESSIONS_DIR = File.join(HOME_DIR, 'sessions')
11
+ MEMORIES_DIR = File.join(HOME_DIR, 'memories')
12
+
13
+ DEFAULT_MODEL = 'claude-opus-4-6'
14
14
  MAX_ITERATIONS = 200
15
- MAX_SUB_AGENT_ITERATIONS = 30
16
- MAX_OUTPUT_CHARS = 50_000
17
- CONTEXT_THRESHOLD_TOKENS = 50_000
18
- MICRO_COMPACT_KEEP_RECENT = 3
15
+ MAX_SUB_AGENT_ITERATIONS = 200
16
+ MAX_EXPLORE_AGENT_ITERATIONS = 200
17
+
18
+ # Output token management (3-tier recovery, matches Claude Code)
19
+ CAPPED_MAX_OUTPUT_TOKENS = 8_000 # Default cap — keeps prompt cache efficient
20
+ ESCALATED_MAX_OUTPUT_TOKENS = 32_000 # Silent escalation on first max_tokens hit
21
+ MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3 # Multi-turn recovery attempts after escalation
22
+
23
+ MAX_OUTPUT_CHARS = 10_000
24
+ MAX_TOOL_RESULT_CHARS = 10_000 # Per-tool result cap
25
+ MAX_MESSAGE_TOOL_RESULTS_CHARS = 50_000 # Aggregate cap for all tool results in one message
26
+ CONTEXT_THRESHOLD_TOKENS = 80_000
27
+ MICRO_COMPACT_KEEP_RECENT = 2
19
28
 
20
29
  POLL_INTERVAL = 5
21
30
  IDLE_TIMEOUT = 60
@@ -23,15 +32,15 @@ module RubynCode
23
32
  SESSION_BUDGET_USD = 5.00
24
33
  DAILY_BUDGET_USD = 10.00
25
34
 
26
- OAUTH_CLIENT_ID = "rubyn-code"
27
- OAUTH_REDIRECT_URI = "http://localhost:19275/callback"
28
- OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
29
- OAUTH_TOKEN_URL = "https://claude.ai/oauth/token"
30
- OAUTH_SCOPES = "user:read model:read model:write"
35
+ OAUTH_CLIENT_ID = 'rubyn-code'
36
+ OAUTH_REDIRECT_URI = 'http://localhost:19275/callback'
37
+ OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
38
+ OAUTH_TOKEN_URL = 'https://claude.ai/oauth/token'
39
+ OAUTH_SCOPES = 'user:read model:read model:write'
31
40
 
32
41
  DANGEROUS_PATTERNS = [
33
- "rm -rf /", "sudo rm", "shutdown", "reboot",
34
- "> /dev/", "mkfs", "dd if=", ":(){:|:&};:"
42
+ 'rm -rf /', 'sudo rm', 'shutdown', 'reboot',
43
+ '> /dev/', 'mkfs', 'dd if=', ':(){:|:&};:'
35
44
  ].freeze
36
45
 
37
46
  SCRUB_ENV_VARS = %w[
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "fileutils"
5
- require_relative "defaults"
6
- require_relative "settings"
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require_relative 'defaults'
6
+ require_relative 'settings'
7
7
 
8
8
  module RubynCode
9
9
  module Config
10
10
  class ProjectConfig
11
11
  class LoadError < StandardError; end
12
12
 
13
- PROJECT_DIR_NAME = ".rubyn-code"
14
- CONFIG_FILENAME = "config.yml"
13
+ PROJECT_DIR_NAME = '.rubyn-code'
14
+ CONFIG_FILENAME = 'config.yml'
15
15
 
16
16
  attr_reader :project_root, :config_path, :data
17
17
 
@@ -73,9 +73,7 @@ module RubynCode
73
73
 
74
74
  loop do
75
75
  candidate = File.join(dir, PROJECT_DIR_NAME, CONFIG_FILENAME)
76
- if File.exist?(candidate)
77
- return new(project_root: dir, global_settings: global_settings)
78
- end
76
+ return new(project_root: dir, global_settings: global_settings) if File.exist?(candidate)
79
77
 
80
78
  parent = File.dirname(dir)
81
79
  break if parent == dir # filesystem root reached
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "fileutils"
5
- require_relative "defaults"
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require_relative 'defaults'
6
6
 
7
7
  module RubynCode
8
8
  module Config
@@ -0,0 +1,20 @@
1
+ # Layer 4: Context Management
2
+
3
+ Manages the conversation context window to stay within Claude's token limits.
4
+
5
+ ## Classes
6
+
7
+ - **`Manager`** — Orchestrates context strategy. Tracks token usage, decides when
8
+ compaction is needed, selects the right compaction strategy.
9
+
10
+ - **`Compactor`** — Base compaction logic. Sends the conversation to Claude with a
11
+ summarization prompt, replaces old messages with the summary.
12
+
13
+ - **`AutoCompact`** — Triggers automatically when token usage exceeds a threshold
14
+ (e.g. 80% of the context window). Runs transparently mid-conversation.
15
+
16
+ - **`MicroCompact`** — Lightweight compaction that trims tool results (large file
17
+ contents, long bash outputs) without summarizing the whole conversation.
18
+
19
+ - **`ManualCompact`** — Triggered by the user via `/compact`. Lets you specify a
20
+ focus area for the summary (e.g. "focus on the auth refactor").
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "fileutils"
3
+ require 'json'
4
+ require 'fileutils'
5
5
 
6
6
  module RubynCode
7
7
  module Context
@@ -33,13 +33,13 @@ module RubynCode
33
33
  transcript_text = serialize_tail(messages, MAX_TRANSCRIPT_CHARS)
34
34
  summary = request_summary(transcript_text, llm_client)
35
35
 
36
- [{ role: "user", content: "[Context compacted]\n\n#{summary}" }]
36
+ [{ role: 'user', content: "[Context compacted]\n\n#{summary}" }]
37
37
  end
38
38
 
39
39
  # Persists the full conversation to a timestamped JSON file.
40
40
  def self.save_transcript(messages, dir)
41
41
  FileUtils.mkdir_p(dir)
42
- timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
42
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
43
43
  path = File.join(dir, "transcript_#{timestamp}.json")
44
44
  File.write(path, JSON.pretty_generate(messages))
45
45
  path
@@ -57,19 +57,19 @@ module RubynCode
57
57
  def self.request_summary(transcript_text, llm_client)
58
58
  summary_messages = [
59
59
  {
60
- role: "user",
60
+ role: 'user',
61
61
  content: "#{SUMMARY_INSTRUCTION}\n\n---\n\n#{transcript_text}"
62
62
  }
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-20250514' if llm_client.respond_to?(:chat)
67
67
 
68
68
  response = llm_client.chat(messages: summary_messages, **options)
69
69
 
70
70
  case response
71
71
  when String then response
72
- when Hash then response[:content] || response["content"] || response.to_s
72
+ when Hash then response[:content] || response['content'] || response.to_s
73
73
  else
74
74
  response.respond_to?(:text) ? response.text : response.to_s
75
75
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module RubynCode
6
6
  module Context
@@ -82,7 +82,7 @@ module RubynCode
82
82
  def ensure_llm_client!
83
83
  return if @llm_client
84
84
 
85
- raise RubynCode::Error, "LLM client is required for summarization-based compaction"
85
+ raise RubynCode::Error, 'LLM client is required for summarization-based compaction'
86
86
  end
87
87
  end
88
88
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Context
5
+ # Lightweight context reduction that removes old conversation turns without
6
+ # calling the LLM. Runs before auto-compact — if collapse alone brings the
7
+ # context under threshold, the expensive LLM summarization is skipped.
8
+ #
9
+ # Keeps the first message (initial user request), the most recent N exchanges,
10
+ # and replaces everything in between with a "[earlier conversation snipped]" marker.
11
+ module ContextCollapse
12
+ SNIP_MARKER = '[%d earlier messages snipped for context efficiency]'
13
+ CHARS_PER_TOKEN = 4
14
+
15
+ # Returns a collapsed copy of messages if doing so brings the estimated
16
+ # token count under threshold. Returns nil if collapse isn't sufficient
17
+ # (caller should fall through to full auto-compact).
18
+ #
19
+ # @param messages [Array<Hash>] conversation messages
20
+ # @param threshold [Integer] target token count
21
+ # @param keep_recent [Integer] number of recent messages to preserve
22
+ # @return [Array<Hash>, nil] collapsed messages or nil if not sufficient
23
+ def self.call(messages, threshold:, keep_recent: 6)
24
+ return nil if messages.size <= keep_recent + 2
25
+
26
+ # Keep first message + last N messages, snip the middle
27
+ first = messages.first
28
+ recent = messages.last(keep_recent)
29
+ snipped_count = messages.size - keep_recent - 1
30
+
31
+ collapsed = [
32
+ first,
33
+ { role: 'user', content: format(SNIP_MARKER, snipped_count) },
34
+ *recent
35
+ ]
36
+
37
+ # Only use collapse if it gets us under threshold
38
+ estimated = (JSON.generate(collapsed).length.to_f / CHARS_PER_TOKEN).ceil
39
+ estimated <= threshold ? collapsed : nil
40
+ rescue JSON::GeneratorError
41
+ nil
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module RubynCode
6
6
  module Context
@@ -13,7 +13,7 @@ module RubynCode
13
13
  attr_reader :total_input_tokens, :total_output_tokens
14
14
 
15
15
  # @param threshold [Integer] estimated token count that triggers auto-compaction
16
- def initialize(threshold: 50_000)
16
+ def initialize(threshold: Config::Defaults::CONTEXT_THRESHOLD_TOKENS)
17
17
  @threshold = threshold
18
18
  @total_input_tokens = 0
19
19
  @total_output_tokens = 0
@@ -53,13 +53,30 @@ module RubynCode
53
53
  #
54
54
  # @param conversation [#messages, #messages=] conversation wrapper
55
55
  # @return [void]
56
+ # Fraction of the compaction threshold at which micro-compact kicks in.
57
+ # Running it too early busts the prompt cache prefix (mutated messages
58
+ # change the hash, invalidating server-side cached tokens).
59
+ MICRO_COMPACT_RATIO = 0.7
60
+
56
61
  def check_compaction!(conversation)
57
62
  messages = conversation.messages
58
63
 
59
- MicroCompact.call(messages)
64
+ # Step 1: Zero-cost micro-compact — but only when we're approaching
65
+ # the compaction threshold. Running it every turn mutates old messages,
66
+ # which invalidates the prompt cache prefix and wastes tokens.
67
+ est = estimated_tokens(messages)
68
+ MicroCompact.call(messages) if est > (@threshold * MICRO_COMPACT_RATIO)
60
69
 
61
70
  return unless needs_compaction?(messages)
62
71
 
72
+ # Step 2: Try context collapse (snip old messages, no LLM call)
73
+ collapsed = ContextCollapse.call(messages, threshold: @threshold)
74
+ if collapsed
75
+ apply_compacted_messages(conversation, collapsed)
76
+ return
77
+ end
78
+
79
+ # Step 3: Full LLM-driven auto-compact (expensive, last resort)
63
80
  compactor = Compactor.new(
64
81
  llm_client: conversation.respond_to?(:llm_client) ? conversation.llm_client : nil,
65
82
  threshold: @threshold