rubyn-code 0.1.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 (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "rouge"
5
+
6
+ module RubynCode
7
+ module CLI
8
+ # Formats streamed text on-the-fly with markdown rendering.
9
+ # Buffers code blocks until they close, then syntax-highlights them.
10
+ # Applies inline formatting (bold, code, headers) as text arrives.
11
+ class StreamFormatter
12
+ def initialize(renderer = nil)
13
+ @pastel = Pastel.new
14
+ @rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
15
+ @buffer = +""
16
+ @in_code_block = false
17
+ @code_lang = nil
18
+ @code_buffer = +""
19
+ end
20
+
21
+ # Feed a chunk of streamed text
22
+ def feed(text)
23
+ @buffer << text
24
+
25
+ # Process complete lines from the buffer
26
+ while (newline_idx = @buffer.index("\n"))
27
+ line = @buffer.slice!(0, newline_idx + 1)
28
+ process_line(line)
29
+ end
30
+
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
37
+ end
38
+
39
+ # Flush any remaining buffered content
40
+ def flush
41
+ unless @buffer.empty?
42
+ if @in_code_block
43
+ @code_buffer << @buffer
44
+ render_code_block
45
+ else
46
+ $stdout.print format_inline(@buffer)
47
+ end
48
+ @buffer = +""
49
+ end
50
+
51
+ # Flush unclosed code block
52
+ render_code_block if @in_code_block && !@code_buffer.empty?
53
+ $stdout.flush
54
+ end
55
+
56
+ private
57
+
58
+ def process_line(line)
59
+ stripped = line.rstrip
60
+
61
+ # Code block toggle
62
+ if stripped.match?(/\A\s*```/)
63
+ if @in_code_block
64
+ # Closing fence — render the buffered code
65
+ render_code_block
66
+ @in_code_block = false
67
+ @code_lang = nil
68
+ else
69
+ # Opening fence
70
+ @in_code_block = true
71
+ @code_lang = stripped.match(/```(\w*)/)[1]
72
+ @code_lang = "ruby" if @code_lang.empty?
73
+ @code_buffer = +""
74
+ $stdout.puts @pastel.dim(" ┌─ #{@code_lang}")
75
+ end
76
+ return
77
+ end
78
+
79
+ if @in_code_block
80
+ @code_buffer << line
81
+ return
82
+ end
83
+
84
+ # Regular line — format and print
85
+ $stdout.print format_line(line)
86
+ $stdout.flush
87
+ end
88
+
89
+ def render_code_block
90
+ return if @code_buffer.empty?
91
+
92
+ lexer = Rouge::Lexer.find(@code_lang || "ruby") || Rouge::Lexers::PlainText.new
93
+ highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
94
+ border = @pastel.dim(" │ ")
95
+
96
+ highlighted.each_line do |l|
97
+ $stdout.print "#{border}#{l}"
98
+ end
99
+ $stdout.puts @pastel.dim(" └─")
100
+ $stdout.flush
101
+
102
+ @code_buffer = +""
103
+ rescue StandardError
104
+ # Fallback: print unformatted
105
+ @code_buffer.each_line { |l| $stdout.print " #{l}" }
106
+ $stdout.puts
107
+ @code_buffer = +""
108
+ end
109
+
110
+ def format_line(line)
111
+ stripped = line.rstrip
112
+
113
+ # Headers
114
+ if stripped.match?(/\A\#{1,6}\s/)
115
+ level = stripped.match(/\A(\#{1,6})\s/)[1].length
116
+ text = stripped.sub(/\A\#{1,6}\s+/, "")
117
+ case level
118
+ when 1 then "#{@pastel.bold.underline(text)}\n"
119
+ when 2 then "\n#{@pastel.bold(text)}\n"
120
+ else "#{@pastel.bold(text)}\n"
121
+ end
122
+ # Bullet lists
123
+ elsif stripped.match?(/\A\s*[-*]\s/)
124
+ indent = stripped.match(/\A(\s*)/)[1]
125
+ content = stripped.sub(/\A\s*[-*]\s+/, "")
126
+ "#{indent} #{@pastel.cyan("•")} #{format_inline(content)}\n"
127
+ # Numbered lists
128
+ elsif stripped.match?(/\A\s*\d+\.\s/)
129
+ indent = stripped.match(/\A(\s*)/)[1]
130
+ num = stripped.match(/(\d+)\./)[1]
131
+ content = stripped.sub(/\A\s*\d+\.\s+/, "")
132
+ "#{indent} #{@pastel.cyan(num + ".")} #{format_inline(content)}\n"
133
+ # Horizontal rules
134
+ elsif stripped.match?(/\A-{3,}\z/)
135
+ "#{@pastel.dim("─" * 40)}\n"
136
+ else
137
+ "#{format_inline(line.chomp)}\n"
138
+ end
139
+ end
140
+
141
+ def format_inline(text)
142
+ text
143
+ .gsub(/\*\*(.+?)\*\*/) { @pastel.bold(Regexp.last_match(1)) }
144
+ .gsub(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/) { @pastel.italic(Regexp.last_match(1)) }
145
+ .gsub(/`([^`]+)`/) { @pastel.cyan(Regexp.last_match(1)) }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Config
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"
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
19
+
20
+ POLL_INTERVAL = 5
21
+ IDLE_TIMEOUT = 60
22
+
23
+ SESSION_BUDGET_USD = 5.00
24
+ DAILY_BUDGET_USD = 10.00
25
+
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"
31
+
32
+ DANGEROUS_PATTERNS = [
33
+ "rm -rf /", "sudo rm", "shutdown", "reboot",
34
+ "> /dev/", "mkfs", "dd if=", ":(){:|:&};:"
35
+ ].freeze
36
+
37
+ SCRUB_ENV_VARS = %w[
38
+ API_KEY SECRET TOKEN PASSWORD CREDENTIAL
39
+ PRIVATE_KEY ACCESS_KEY SESSION_KEY
40
+ ].freeze
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require_relative "defaults"
6
+ require_relative "settings"
7
+
8
+ module RubynCode
9
+ module Config
10
+ class ProjectConfig
11
+ class LoadError < StandardError; end
12
+
13
+ PROJECT_DIR_NAME = ".rubyn-code"
14
+ CONFIG_FILENAME = "config.yml"
15
+
16
+ attr_reader :project_root, :config_path, :data
17
+
18
+ # @param project_root [String] the root directory of the project (defaults to pwd)
19
+ # @param global_settings [Settings, nil] global settings to fall back to
20
+ def initialize(project_root: Dir.pwd, global_settings: nil)
21
+ @project_root = File.expand_path(project_root)
22
+ @project_dir = File.join(@project_root, PROJECT_DIR_NAME)
23
+ @config_path = File.join(@project_dir, CONFIG_FILENAME)
24
+ @global_settings = global_settings || Settings.new
25
+ @data = {}
26
+ load!
27
+ end
28
+
29
+ def get(key, default = nil)
30
+ @data.fetch(key.to_s) { @global_settings.get(key, default) }
31
+ end
32
+
33
+ def set(key, value)
34
+ @data[key.to_s] = value
35
+ end
36
+
37
+ # Dynamically delegate configurable keys: project-level overrides global
38
+ Settings::CONFIGURABLE_KEYS.each do |key|
39
+ define_method(key) do
40
+ @data.fetch(key.to_s) { @global_settings.public_send(key) }
41
+ end
42
+
43
+ define_method(:"#{key}=") do |value|
44
+ @data[key.to_s] = value
45
+ end
46
+ end
47
+
48
+ def save!
49
+ ensure_project_directory!
50
+ File.write(@config_path, YAML.dump(@data))
51
+ rescue Errno::EACCES => e
52
+ raise LoadError, "Permission denied writing project config to #{@config_path}: #{e.message}"
53
+ rescue SystemCallError => e
54
+ raise LoadError, "Failed to save project config to #{@config_path}: #{e.message}"
55
+ end
56
+
57
+ def reload!
58
+ load!
59
+ end
60
+
61
+ def to_h
62
+ @global_settings.to_h.merge(@data)
63
+ end
64
+
65
+ def project_dir_exists?
66
+ File.directory?(@project_dir)
67
+ end
68
+
69
+ # Walks up the directory tree to find the nearest .rubyn-code/config.yml
70
+ # Returns nil if none is found before reaching the filesystem root.
71
+ def self.find_nearest(start_dir: Dir.pwd, global_settings: nil)
72
+ dir = File.expand_path(start_dir)
73
+
74
+ loop do
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
79
+
80
+ parent = File.dirname(dir)
81
+ break if parent == dir # filesystem root reached
82
+
83
+ dir = parent
84
+ end
85
+
86
+ nil
87
+ end
88
+
89
+ private
90
+
91
+ def ensure_project_directory!
92
+ return if File.directory?(@project_dir)
93
+
94
+ FileUtils.mkdir_p(@project_dir)
95
+ rescue SystemCallError => e
96
+ raise LoadError, "Cannot create project config directory #{@project_dir}: #{e.message}"
97
+ end
98
+
99
+ def load!
100
+ return unless File.exist?(@config_path)
101
+
102
+ content = File.read(@config_path)
103
+ return if content.strip.empty?
104
+
105
+ parsed = YAML.safe_load(content, permitted_classes: [Symbol])
106
+
107
+ case parsed
108
+ in Hash => h
109
+ @data = h.transform_keys(&:to_s)
110
+ else
111
+ raise LoadError, "Expected a YAML mapping in #{@config_path}, got #{parsed.class}"
112
+ end
113
+ rescue Psych::SyntaxError => e
114
+ raise LoadError, "Malformed YAML in #{@config_path}: #{e.message}"
115
+ rescue Errno::EACCES => e
116
+ raise LoadError, "Permission denied reading #{@config_path}: #{e.message}"
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require_relative "defaults"
6
+
7
+ module RubynCode
8
+ module Config
9
+ class Settings
10
+ class LoadError < StandardError; end
11
+
12
+ CONFIGURABLE_KEYS = %i[
13
+ model max_iterations max_sub_agent_iterations max_output_chars
14
+ context_threshold_tokens micro_compact_keep_recent
15
+ poll_interval idle_timeout
16
+ session_budget_usd daily_budget_usd
17
+ oauth_client_id oauth_redirect_uri oauth_authorize_url
18
+ oauth_token_url oauth_scopes
19
+ ].freeze
20
+
21
+ DEFAULT_MAP = {
22
+ model: Defaults::DEFAULT_MODEL,
23
+ max_iterations: Defaults::MAX_ITERATIONS,
24
+ max_sub_agent_iterations: Defaults::MAX_SUB_AGENT_ITERATIONS,
25
+ max_output_chars: Defaults::MAX_OUTPUT_CHARS,
26
+ context_threshold_tokens: Defaults::CONTEXT_THRESHOLD_TOKENS,
27
+ micro_compact_keep_recent: Defaults::MICRO_COMPACT_KEEP_RECENT,
28
+ poll_interval: Defaults::POLL_INTERVAL,
29
+ idle_timeout: Defaults::IDLE_TIMEOUT,
30
+ session_budget_usd: Defaults::SESSION_BUDGET_USD,
31
+ daily_budget_usd: Defaults::DAILY_BUDGET_USD,
32
+ oauth_client_id: Defaults::OAUTH_CLIENT_ID,
33
+ oauth_redirect_uri: Defaults::OAUTH_REDIRECT_URI,
34
+ oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
35
+ oauth_token_url: Defaults::OAUTH_TOKEN_URL,
36
+ oauth_scopes: Defaults::OAUTH_SCOPES
37
+ }.freeze
38
+
39
+ attr_reader :config_path, :data
40
+
41
+ def initialize(config_path: Defaults::CONFIG_FILE)
42
+ @config_path = config_path
43
+ @data = {}
44
+ ensure_home_directory!
45
+ load!
46
+ end
47
+
48
+ # Define accessor methods for each configurable key
49
+ CONFIGURABLE_KEYS.each do |key|
50
+ define_method(key) do
51
+ @data.fetch(key.to_s, DEFAULT_MAP[key])
52
+ end
53
+
54
+ define_method(:"#{key}=") do |value|
55
+ @data[key.to_s] = value
56
+ end
57
+ end
58
+
59
+ def get(key, default = nil)
60
+ sym = key.to_sym
61
+ @data.fetch(key.to_s) { DEFAULT_MAP.fetch(sym, default) }
62
+ end
63
+
64
+ def set(key, value)
65
+ @data[key.to_s] = value
66
+ end
67
+
68
+ def save!
69
+ ensure_home_directory!
70
+ File.write(@config_path, YAML.dump(@data))
71
+ File.chmod(0o600, @config_path)
72
+ rescue Errno::EACCES => e
73
+ raise LoadError, "Permission denied writing config to #{@config_path}: #{e.message}"
74
+ rescue SystemCallError => e
75
+ raise LoadError, "Failed to save config to #{@config_path}: #{e.message}"
76
+ end
77
+
78
+ def reload!
79
+ load!
80
+ end
81
+
82
+ def to_h
83
+ DEFAULT_MAP.transform_keys(&:to_s).merge(@data)
84
+ end
85
+
86
+ def home_dir = Defaults::HOME_DIR
87
+ def db_file = Defaults::DB_FILE
88
+ def tokens_file = Defaults::TOKENS_FILE
89
+ def sessions_dir = Defaults::SESSIONS_DIR
90
+ def memories_dir = Defaults::MEMORIES_DIR
91
+
92
+ def dangerous_patterns = Defaults::DANGEROUS_PATTERNS
93
+ def scrub_env_vars = Defaults::SCRUB_ENV_VARS
94
+
95
+ private
96
+
97
+ def ensure_home_directory!
98
+ dir = File.dirname(@config_path)
99
+ return if File.directory?(dir)
100
+
101
+ FileUtils.mkdir_p(dir, mode: 0o700)
102
+ rescue SystemCallError => e
103
+ raise LoadError, "Cannot create config directory #{dir}: #{e.message}"
104
+ end
105
+
106
+ def load!
107
+ return unless File.exist?(@config_path)
108
+
109
+ content = File.read(@config_path)
110
+ return if content.strip.empty?
111
+
112
+ parsed = YAML.safe_load(content, permitted_classes: [Symbol])
113
+
114
+ case parsed
115
+ in Hash => h
116
+ @data = h.transform_keys(&:to_s)
117
+ else
118
+ raise LoadError, "Expected a YAML mapping in #{@config_path}, got #{parsed.class}"
119
+ end
120
+ rescue Psych::SyntaxError => e
121
+ raise LoadError, "Malformed YAML in #{@config_path}: #{e.message}"
122
+ rescue Errno::EACCES => e
123
+ raise LoadError, "Permission denied reading #{@config_path}: #{e.message}"
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module RubynCode
7
+ module Context
8
+ # LLM-driven summarization triggered automatically when the context window
9
+ # grows too large. Serializes the conversation tail, asks the LLM to produce
10
+ # a continuity summary, and returns a fresh single-message conversation.
11
+ module AutoCompact
12
+ SUMMARY_INSTRUCTION = <<~PROMPT
13
+ You are a context compaction assistant. Summarize the following conversation transcript for continuity. Cover exactly three areas:
14
+
15
+ 1) **What was accomplished** - completed tasks, files changed, problems solved
16
+ 2) **Current state** - what the user/agent is working on right now, any pending actions
17
+ 3) **Key decisions made** - architectural choices, user preferences, constraints established
18
+
19
+ Be concise but preserve all details needed to continue the work seamlessly. Use bullet points.
20
+ PROMPT
21
+
22
+ MAX_TRANSCRIPT_CHARS = 80_000
23
+
24
+ # Compacts the conversation by summarizing it through the LLM.
25
+ #
26
+ # @param messages [Array<Hash>] current conversation messages
27
+ # @param llm_client [#chat] an LLM client that responds to #chat
28
+ # @param transcript_dir [String, nil] directory to save full transcript before compaction
29
+ # @return [Array<Hash>] new messages array containing only the summary
30
+ def self.call(messages, llm_client:, transcript_dir: nil)
31
+ save_transcript(messages, transcript_dir) if transcript_dir
32
+
33
+ transcript_text = serialize_tail(messages, MAX_TRANSCRIPT_CHARS)
34
+ summary = request_summary(transcript_text, llm_client)
35
+
36
+ [{ role: "user", content: "[Context compacted]\n\n#{summary}" }]
37
+ end
38
+
39
+ # Persists the full conversation to a timestamped JSON file.
40
+ def self.save_transcript(messages, dir)
41
+ FileUtils.mkdir_p(dir)
42
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
43
+ path = File.join(dir, "transcript_#{timestamp}.json")
44
+ File.write(path, JSON.pretty_generate(messages))
45
+ path
46
+ end
47
+
48
+ # Takes the last +max_chars+ of the JSON-serialized messages.
49
+ def self.serialize_tail(messages, max_chars)
50
+ json = JSON.generate(messages)
51
+ return json if json.length <= max_chars
52
+
53
+ json[-max_chars..]
54
+ end
55
+
56
+ # Sends the transcript to the LLM for summarization.
57
+ def self.request_summary(transcript_text, llm_client)
58
+ summary_messages = [
59
+ {
60
+ role: "user",
61
+ content: "#{SUMMARY_INSTRUCTION}\n\n---\n\n#{transcript_text}"
62
+ }
63
+ ]
64
+
65
+ options = {}
66
+ options[:model] = "claude-sonnet-4-20250514" if llm_client.respond_to?(:chat)
67
+
68
+ response = llm_client.chat(messages: summary_messages, **options)
69
+
70
+ case response
71
+ when String then response
72
+ when Hash then response[:content] || response["content"] || response.to_s
73
+ else
74
+ response.respond_to?(:text) ? response.text : response.to_s
75
+ end
76
+ end
77
+
78
+ private_class_method :save_transcript, :serialize_tail, :request_summary
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubynCode
6
+ module Context
7
+ # Facade that coordinates the three compaction strategies: micro (every turn),
8
+ # auto (when threshold is exceeded), and manual (user-triggered via /compact).
9
+ class Compactor
10
+ CHARS_PER_TOKEN = 4
11
+
12
+ # @param llm_client [#chat, nil] LLM client for summarization-based compaction
13
+ # @param threshold [Integer] estimated token count that triggers auto-compaction
14
+ # @param transcript_dir [String, nil] directory to persist transcripts before compaction
15
+ def initialize(llm_client:, threshold: 50_000, transcript_dir: nil)
16
+ @llm_client = llm_client
17
+ @threshold = threshold
18
+ @transcript_dir = transcript_dir
19
+ end
20
+
21
+ # Runs zero-cost micro-compaction on old tool results. Mutates messages
22
+ # in place and returns the count of compacted results.
23
+ #
24
+ # @param messages [Array<Hash>] conversation messages
25
+ # @return [Integer] number of tool results compacted
26
+ def micro_compact!(messages)
27
+ MicroCompact.call(messages)
28
+ end
29
+
30
+ # Runs LLM-driven auto-compaction, replacing the full conversation with a
31
+ # continuity summary. Returns a new messages array.
32
+ #
33
+ # @param messages [Array<Hash>] conversation messages
34
+ # @return [Array<Hash>] compacted messages (single summary message)
35
+ # @raise [RubynCode::Error] if no LLM client is configured
36
+ def auto_compact!(messages)
37
+ ensure_llm_client!
38
+
39
+ AutoCompact.call(
40
+ messages,
41
+ llm_client: @llm_client,
42
+ transcript_dir: @transcript_dir
43
+ )
44
+ end
45
+
46
+ # Runs LLM-driven manual compaction, optionally guided by a focus prompt.
47
+ # Returns a new messages array.
48
+ #
49
+ # @param messages [Array<Hash>] conversation messages
50
+ # @param focus [String, nil] optional user-supplied focus to guide summarization
51
+ # @return [Array<Hash>] compacted messages (single summary message)
52
+ # @raise [RubynCode::Error] if no LLM client is configured
53
+ def manual_compact!(messages, focus: nil)
54
+ ensure_llm_client!
55
+
56
+ ManualCompact.call(
57
+ messages,
58
+ llm_client: @llm_client,
59
+ transcript_dir: @transcript_dir,
60
+ focus: focus
61
+ )
62
+ end
63
+
64
+ # Checks whether the estimated token count for the messages exceeds the
65
+ # configured threshold.
66
+ #
67
+ # @param messages [Array<Hash>] conversation messages
68
+ # @return [Boolean]
69
+ def should_auto_compact?(messages)
70
+ estimated_tokens(messages) > @threshold
71
+ end
72
+
73
+ private
74
+
75
+ def estimated_tokens(messages)
76
+ json = JSON.generate(messages)
77
+ (json.length.to_f / CHARS_PER_TOKEN).ceil
78
+ rescue JSON::GeneratorError
79
+ 0
80
+ end
81
+
82
+ def ensure_llm_client!
83
+ return if @llm_client
84
+
85
+ raise RubynCode::Error, "LLM client is required for summarization-based compaction"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubynCode
6
+ module Context
7
+ # Orchestrates context management for a session. Tracks cumulative token
8
+ # usage from LLM responses and triggers compaction strategies when the
9
+ # estimated context size exceeds the configured threshold.
10
+ class Manager
11
+ CHARS_PER_TOKEN = 4
12
+
13
+ attr_reader :total_input_tokens, :total_output_tokens
14
+
15
+ # @param threshold [Integer] estimated token count that triggers auto-compaction
16
+ def initialize(threshold: 50_000)
17
+ @threshold = threshold
18
+ @total_input_tokens = 0
19
+ @total_output_tokens = 0
20
+ end
21
+
22
+ # Accumulates token counts from an LLM response usage object.
23
+ #
24
+ # @param usage [LLM::Usage, #input_tokens] usage data from an LLM response
25
+ def track_usage(usage)
26
+ @total_input_tokens += usage.input_tokens.to_i
27
+ @total_output_tokens += usage.output_tokens.to_i
28
+ end
29
+
30
+ # Rough estimate of token count for a set of messages based on their
31
+ # JSON-serialized character length (~4 chars per token).
32
+ #
33
+ # @param messages [Array<Hash>] conversation messages
34
+ # @return [Integer] estimated token count
35
+ def estimated_tokens(messages)
36
+ json = JSON.generate(messages)
37
+ (json.length.to_f / CHARS_PER_TOKEN).ceil
38
+ rescue JSON::GeneratorError
39
+ 0
40
+ end
41
+
42
+ # Returns true if the estimated token count exceeds the threshold.
43
+ #
44
+ # @param messages [Array<Hash>] conversation messages
45
+ # @return [Boolean]
46
+ def needs_compaction?(messages)
47
+ estimated_tokens(messages) > @threshold
48
+ end
49
+
50
+ # Runs micro-compaction every turn and auto-compaction when the context
51
+ # exceeds the threshold. Expects a conversation object that responds to
52
+ # #messages and #messages= (or #replace_messages).
53
+ #
54
+ # @param conversation [#messages, #messages=] conversation wrapper
55
+ # @return [void]
56
+ def check_compaction!(conversation)
57
+ messages = conversation.messages
58
+
59
+ MicroCompact.call(messages)
60
+
61
+ return unless needs_compaction?(messages)
62
+
63
+ compactor = Compactor.new(
64
+ llm_client: conversation.respond_to?(:llm_client) ? conversation.llm_client : nil,
65
+ threshold: @threshold
66
+ )
67
+
68
+ new_messages = compactor.auto_compact!(messages)
69
+ apply_compacted_messages(conversation, new_messages)
70
+ end
71
+
72
+ # Resets cumulative token counters to zero.
73
+ #
74
+ # @return [void]
75
+ def reset!
76
+ @total_input_tokens = 0
77
+ @total_output_tokens = 0
78
+ end
79
+
80
+ private
81
+
82
+ def apply_compacted_messages(conversation, new_messages)
83
+ if conversation.respond_to?(:replace_messages)
84
+ conversation.replace_messages(new_messages)
85
+ elsif conversation.respond_to?(:messages=)
86
+ conversation.messages = new_messages
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end