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,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+ require "base64"
6
+ require "faraday"
7
+ require "json"
8
+
9
+ module RubynCode
10
+ module Auth
11
+ class OAuth
12
+ StateMismatchError = Class.new(RubynCode::AuthenticationError)
13
+ TokenExchangeError = Class.new(RubynCode::AuthenticationError)
14
+ RefreshError = Class.new(RubynCode::AuthenticationError)
15
+
16
+ VERIFIER_LENGTH = 43
17
+
18
+ def authenticate!
19
+ code_verifier = generate_code_verifier
20
+ code_challenge = derive_code_challenge(code_verifier)
21
+ state = SecureRandom.hex(24)
22
+
23
+ auth_url = build_authorization_url(code_challenge:, state:)
24
+
25
+ callback_server = Server.new
26
+ open_browser(auth_url)
27
+
28
+ result = callback_server.wait_for_callback(timeout: 120)
29
+
30
+ unless secure_compare(result[:state], state)
31
+ raise StateMismatchError, "OAuth state parameter mismatch — possible CSRF attack"
32
+ end
33
+
34
+ tokens = exchange_code(code: result[:code], code_verifier:)
35
+
36
+ TokenStore.save(
37
+ access_token: tokens[:access_token],
38
+ refresh_token: tokens[:refresh_token],
39
+ expires_at: Time.now + tokens[:expires_in].to_i
40
+ )
41
+
42
+ tokens
43
+ end
44
+
45
+ def refresh!
46
+ stored = TokenStore.load
47
+ raise RefreshError, "No stored refresh token available" unless stored&.dig(:refresh_token)
48
+
49
+ response = http_client.post(token_url) do |req|
50
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
51
+ req.body = URI.encode_www_form(
52
+ grant_type: "refresh_token",
53
+ client_id: client_id,
54
+ refresh_token: stored[:refresh_token]
55
+ )
56
+ end
57
+
58
+ unless response.success?
59
+ body = parse_json(response.body)
60
+ error_msg = body&.dig("error_description") || body&.dig("error") || response.body
61
+ raise RefreshError, "Token refresh failed (#{response.status}): #{error_msg}"
62
+ end
63
+
64
+ body = parse_json(response.body)
65
+ raise RefreshError, "Invalid response from token endpoint" unless body
66
+
67
+ TokenStore.save(
68
+ access_token: body["access_token"],
69
+ refresh_token: body["refresh_token"] || stored[:refresh_token],
70
+ expires_at: Time.now + body["expires_in"].to_i
71
+ )
72
+
73
+ {
74
+ access_token: body["access_token"],
75
+ refresh_token: body["refresh_token"] || stored[:refresh_token],
76
+ expires_in: body["expires_in"]
77
+ }
78
+ end
79
+
80
+ private
81
+
82
+ def generate_code_verifier
83
+ SecureRandom.urlsafe_base64(32).slice(0, VERIFIER_LENGTH)
84
+ end
85
+
86
+ def derive_code_challenge(verifier)
87
+ digest = Digest::SHA256.digest(verifier)
88
+ Base64.urlsafe_encode64(digest, padding: false)
89
+ end
90
+
91
+ def build_authorization_url(code_challenge:, state:)
92
+ params = URI.encode_www_form(
93
+ response_type: "code",
94
+ client_id: client_id,
95
+ redirect_uri: redirect_uri,
96
+ scope: scopes,
97
+ state: state,
98
+ code_challenge: code_challenge,
99
+ code_challenge_method: "S256"
100
+ )
101
+
102
+ "#{authorize_url}?#{params}"
103
+ end
104
+
105
+ def exchange_code(code:, code_verifier:)
106
+ response = http_client.post(token_url) do |req|
107
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
108
+ req.body = URI.encode_www_form(
109
+ grant_type: "authorization_code",
110
+ client_id: client_id,
111
+ code: code,
112
+ redirect_uri: redirect_uri,
113
+ code_verifier: code_verifier
114
+ )
115
+ end
116
+
117
+ unless response.success?
118
+ body = parse_json(response.body)
119
+ error_msg = body&.dig("error_description") || body&.dig("error") || response.body
120
+ raise TokenExchangeError, "Code exchange failed (#{response.status}): #{error_msg}"
121
+ end
122
+
123
+ body = parse_json(response.body)
124
+ raise TokenExchangeError, "Invalid response from token endpoint" unless body
125
+
126
+ {
127
+ access_token: body["access_token"],
128
+ refresh_token: body["refresh_token"],
129
+ expires_in: body["expires_in"]
130
+ }
131
+ end
132
+
133
+ def open_browser(url)
134
+ launcher = case RUBY_PLATFORM
135
+ when /darwin/ then "open"
136
+ when /linux/ then "xdg-open"
137
+ when /mingw|mswin/ then "start"
138
+ else "xdg-open"
139
+ end
140
+
141
+ system(launcher, url, exception: false)
142
+ end
143
+
144
+ def http_client
145
+ @http_client ||= Faraday.new do |f|
146
+ f.options.timeout = 30
147
+ f.options.open_timeout = 10
148
+ f.adapter Faraday.default_adapter
149
+ end
150
+ end
151
+
152
+ def parse_json(body)
153
+ JSON.parse(body)
154
+ rescue JSON::ParserError
155
+ nil
156
+ end
157
+
158
+ def secure_compare(a, b)
159
+ return false if a.nil? || b.nil?
160
+ return false unless a.bytesize == b.bytesize
161
+
162
+ l = a.unpack("C*")
163
+ r = b.unpack("C*")
164
+ l.zip(r).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
165
+ end
166
+
167
+ def client_id = Config::Defaults::OAUTH_CLIENT_ID
168
+ def redirect_uri = Config::Defaults::OAUTH_REDIRECT_URI
169
+ def authorize_url = Config::Defaults::OAUTH_AUTHORIZE_URL
170
+ def token_url = Config::Defaults::OAUTH_TOKEN_URL
171
+ def scopes = Config::Defaults::OAUTH_SCOPES
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "uri"
5
+
6
+ module RubynCode
7
+ module Auth
8
+ class Server
9
+ LISTEN_HOST = "127.0.0.1"
10
+ LISTEN_PORT = 19_275
11
+
12
+ CallbackTimeout = Class.new(RubynCode::AuthenticationError)
13
+
14
+ def initialize
15
+ @result = nil
16
+ @mutex = Mutex.new
17
+ @condvar = ConditionVariable.new
18
+ end
19
+
20
+ def wait_for_callback(timeout: 120)
21
+ server = build_server
22
+ thread = Thread.new { server.start }
23
+
24
+ @mutex.synchronize do
25
+ @condvar.wait(@mutex, timeout) until @result || timed_out?(timeout)
26
+ end
27
+
28
+ server.shutdown
29
+ thread.join(5)
30
+
31
+ raise CallbackTimeout, "OAuth callback was not received within #{timeout} seconds" unless @result
32
+
33
+ @result
34
+ end
35
+
36
+ private
37
+
38
+ def build_server
39
+ logger = WEBrick::Log.new($stderr, WEBrick::Log::WARN)
40
+ access_log = []
41
+
42
+ server = WEBrick::HTTPServer.new(
43
+ BindAddress: LISTEN_HOST,
44
+ Port: LISTEN_PORT,
45
+ Logger: logger,
46
+ AccessLog: access_log
47
+ )
48
+
49
+ server.mount_proc("/callback") do |req, res|
50
+ handle_callback(req, res, server)
51
+ end
52
+
53
+ server
54
+ end
55
+
56
+ def handle_callback(req, res, server)
57
+ params = parse_query(req.query_string)
58
+ code = params["code"]
59
+ state = params["state"]
60
+
61
+ if code
62
+ @mutex.synchronize do
63
+ @result = { code: code, state: state }
64
+ @condvar.signal
65
+ end
66
+
67
+ res.status = 200
68
+ res.content_type = "text/html; charset=utf-8"
69
+ res.body = success_html
70
+ else
71
+ error = params["error"] || "unknown"
72
+ description = params["error_description"] || "No authorization code received"
73
+
74
+ res.status = 400
75
+ res.content_type = "text/html; charset=utf-8"
76
+ res.body = error_html(error, description)
77
+
78
+ @mutex.synchronize do
79
+ @condvar.signal
80
+ end
81
+ end
82
+
83
+ Thread.new { sleep(0.5); server.shutdown }
84
+ end
85
+
86
+ def parse_query(query_string)
87
+ return {} unless query_string
88
+
89
+ URI.decode_www_form(query_string).to_h
90
+ end
91
+
92
+ def timed_out?(timeout)
93
+ @start_time ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
94
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
95
+ elapsed >= timeout
96
+ end
97
+
98
+ def success_html
99
+ <<~HTML
100
+ <!DOCTYPE html>
101
+ <html>
102
+ <head><title>rubyn-code</title></head>
103
+ <body style="font-family: system-ui, sans-serif; text-align: center; padding: 60px;">
104
+ <h1>Authenticated!</h1>
105
+ <p>You can close this tab and return to your terminal.</p>
106
+ </body>
107
+ </html>
108
+ HTML
109
+ end
110
+
111
+ def error_html(error, description)
112
+ <<~HTML
113
+ <!DOCTYPE html>
114
+ <html>
115
+ <head><title>rubyn-code - Error</title></head>
116
+ <body style="font-family: system-ui, sans-serif; text-align: center; padding: 60px;">
117
+ <h1>Authentication Failed</h1>
118
+ <p><strong>#{WEBrick::HTMLUtils.escape(error)}</strong></p>
119
+ <p>#{WEBrick::HTMLUtils.escape(description)}</p>
120
+ </body>
121
+ </html>
122
+ HTML
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "json"
6
+ require "time"
7
+
8
+ module RubynCode
9
+ module Auth
10
+ module TokenStore
11
+ EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
12
+ KEYCHAIN_SERVICE = "Claude Code-credentials"
13
+
14
+ class << self
15
+ # Load tokens with fallback chain:
16
+ # 1. macOS Keychain (Claude Code's OAuth token)
17
+ # 2. Local YAML file (~/.rubyn-code/tokens.yml)
18
+ # 3. ANTHROPIC_API_KEY environment variable
19
+ def load
20
+ load_from_keychain || load_from_file || load_from_env
21
+ end
22
+
23
+ def save(access_token:, refresh_token:, expires_at:)
24
+ ensure_directory!
25
+
26
+ data = {
27
+ "access_token" => access_token,
28
+ "refresh_token" => refresh_token,
29
+ "expires_at" => expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
30
+ }
31
+
32
+ File.write(tokens_path, YAML.dump(data))
33
+ File.chmod(0o600, tokens_path)
34
+ data
35
+ end
36
+
37
+ def clear!
38
+ File.delete(tokens_path) if File.exist?(tokens_path)
39
+ true
40
+ end
41
+
42
+ def valid?
43
+ tokens = self.load
44
+ return false unless tokens
45
+ return false unless tokens[:access_token]
46
+
47
+ # API keys don't expire
48
+ return true if tokens[:type] == :api_key
49
+
50
+ # OAuth tokens need expiry check
51
+ return true unless tokens[:expires_at]
52
+
53
+ tokens[:expires_at] > Time.now + EXPIRY_BUFFER_SECONDS
54
+ end
55
+
56
+ def exists?
57
+ valid?
58
+ end
59
+
60
+ def access_token
61
+ tokens = self.load
62
+ tokens&.fetch(:access_token, nil)
63
+ end
64
+
65
+ def token_type
66
+ tokens = self.load
67
+ tokens&.fetch(:type, :oauth)
68
+ end
69
+
70
+ private
71
+
72
+ # Read Claude Code's OAuth token from macOS Keychain
73
+ def load_from_keychain
74
+ return nil unless RUBY_PLATFORM.include?("darwin")
75
+
76
+ output = `security find-generic-password -s "#{KEYCHAIN_SERVICE}" -w 2>/dev/null`.strip
77
+ return nil if output.empty?
78
+
79
+ data = JSON.parse(output)
80
+ oauth = data["claudeAiOauth"]
81
+ return nil unless oauth && oauth["accessToken"]
82
+
83
+ expires_at = if oauth["expiresAt"]
84
+ Time.at(oauth["expiresAt"] / 1000.0) # milliseconds to seconds
85
+ end
86
+
87
+ {
88
+ access_token: oauth["accessToken"],
89
+ refresh_token: oauth["refreshToken"],
90
+ expires_at: expires_at,
91
+ type: :oauth,
92
+ source: :keychain
93
+ }
94
+ rescue JSON::ParserError, StandardError
95
+ nil
96
+ end
97
+
98
+ # Read from local YAML token file
99
+ def load_from_file
100
+ return nil unless File.exist?(tokens_path)
101
+
102
+ data = YAML.safe_load_file(tokens_path, permitted_classes: [Time])
103
+ return nil unless data.is_a?(Hash)
104
+ return nil unless data["access_token"]
105
+
106
+ {
107
+ access_token: data["access_token"],
108
+ refresh_token: data["refresh_token"],
109
+ expires_at: parse_time(data["expires_at"]),
110
+ type: :oauth,
111
+ source: :file
112
+ }
113
+ rescue Psych::SyntaxError, Errno::EACCES
114
+ nil
115
+ end
116
+
117
+ # Fall back to ANTHROPIC_API_KEY environment variable
118
+ def load_from_env
119
+ api_key = ENV["ANTHROPIC_API_KEY"]
120
+ return nil unless api_key && !api_key.empty?
121
+
122
+ {
123
+ access_token: api_key,
124
+ refresh_token: nil,
125
+ expires_at: nil,
126
+ type: :api_key,
127
+ source: :env
128
+ }
129
+ end
130
+
131
+ def tokens_path
132
+ Config::Defaults::TOKENS_FILE
133
+ end
134
+
135
+ def ensure_directory!
136
+ dir = File.dirname(tokens_path)
137
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
138
+ File.chmod(0o700, dir)
139
+ end
140
+
141
+ def parse_time(value)
142
+ case value
143
+ when Time then value
144
+ when String then Time.parse(value)
145
+ when Integer, Float then Time.at(value)
146
+ end
147
+ rescue ArgumentError
148
+ nil
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RubynCode
6
+ module Autonomous
7
+ # The KAIROS daemon -- an always-on autonomous agent that cycles between
8
+ # working on tasks and polling for new work. The lifecycle is:
9
+ #
10
+ # spawn -> work -> idle -> work -> ... -> shutdown
11
+ #
12
+ # Safety limits (max_runs, max_cost) prevent runaway execution.
13
+ class Daemon
14
+ LIFECYCLE_STATES = %i[spawned working idle shutting_down stopped].freeze
15
+
16
+ attr_reader :agent_name, :role, :state, :runs_completed, :total_cost
17
+
18
+ # @param agent_name [String] unique name for this daemon instance
19
+ # @param role [String] the agent's role / persona description
20
+ # @param llm_client [LLM::Client] LLM API client
21
+ # @param project_root [String] path to the project being worked on
22
+ # @param task_manager [#db] task persistence layer
23
+ # @param mailbox [#pending_for] message mailbox
24
+ # @param max_runs [Integer] maximum work cycles before auto-shutdown (default 100)
25
+ # @param max_cost [Float] maximum cumulative LLM cost in USD before auto-shutdown (default 10.0)
26
+ # @param poll_interval [Numeric] idle polling interval in seconds (default 5)
27
+ # @param idle_timeout [Numeric] seconds of idle before shutdown (default 60)
28
+ # @param on_state_change [Proc, nil] callback invoked with (old_state, new_state)
29
+ def initialize(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:, # rubocop:disable Metrics/ParameterLists
30
+ max_runs: 100, max_cost: 10.0, poll_interval: 5, idle_timeout: 60,
31
+ on_state_change: nil)
32
+ @agent_name = agent_name
33
+ @role = role
34
+ @llm_client = llm_client
35
+ @project_root = File.expand_path(project_root)
36
+ @task_manager = task_manager
37
+ @mailbox = mailbox
38
+ @max_runs = max_runs
39
+ @max_cost = max_cost
40
+ @poll_interval = poll_interval
41
+ @idle_timeout = idle_timeout
42
+ @on_state_change = on_state_change
43
+
44
+ @state = :spawned
45
+ @runs_completed = 0
46
+ @total_cost = 0.0
47
+ @stop_requested = false
48
+ end
49
+
50
+ # Enters the work-idle-work cycle. Blocks the calling thread until
51
+ # the daemon shuts down (via safety limits, idle timeout, or #stop!).
52
+ #
53
+ # @return [Symbol] the final state (:stopped)
54
+ def start!
55
+ transition_to(:working)
56
+
57
+ loop do
58
+ break if @stop_requested
59
+ break if safety_limit_reached?
60
+
61
+ task = TaskClaimer.call(task_manager: @task_manager, agent_name: @agent_name)
62
+
63
+ if task
64
+ run_work_phase(task)
65
+ @runs_completed += 1
66
+ else
67
+ result = run_idle_phase
68
+ case result
69
+ when :shutdown, :interrupted
70
+ break
71
+ when :resume
72
+ transition_to(:working)
73
+ next
74
+ end
75
+ end
76
+ end
77
+
78
+ shutdown!
79
+ end
80
+
81
+ # Requests a graceful shutdown. The daemon will finish its current
82
+ # work unit and then stop.
83
+ #
84
+ # @return [void]
85
+ def stop!
86
+ @stop_requested = true
87
+ @idle_poller&.interrupt!
88
+ end
89
+
90
+ # @return [Boolean]
91
+ def running?
92
+ %i[working idle].include?(@state)
93
+ end
94
+
95
+ # @return [Hash] snapshot of daemon status
96
+ def status
97
+ {
98
+ agent_name: @agent_name,
99
+ role: @role,
100
+ state: @state,
101
+ runs_completed: @runs_completed,
102
+ total_cost: @total_cost,
103
+ max_runs: @max_runs,
104
+ max_cost: @max_cost,
105
+ stop_requested: @stop_requested
106
+ }
107
+ end
108
+
109
+ private
110
+
111
+ # Executes the agent loop for a single claimed task.
112
+ #
113
+ # @param task [Tasks::Task]
114
+ # @return [void]
115
+ def run_work_phase(task)
116
+ transition_to(:working)
117
+
118
+ conversation = Agent::Conversation.new
119
+ conversation.add_user_message(build_work_prompt(task))
120
+
121
+ response = @llm_client.chat(
122
+ messages: conversation.to_api_format,
123
+ system: build_system_prompt
124
+ )
125
+
126
+ track_cost(response)
127
+
128
+ # Mark the task as completed with the agent's result.
129
+ result_text = extract_result(response)
130
+ @task_manager.db.execute(
131
+ "UPDATE tasks SET status = 'completed', result = ?, updated_at = datetime('now') WHERE id = ?",
132
+ [result_text, task.id]
133
+ )
134
+ rescue StandardError => e
135
+ # On failure, release the task so another agent (or retry) can pick it up.
136
+ @task_manager.db.execute(
137
+ "UPDATE tasks SET status = 'pending', owner = NULL, result = ?, updated_at = datetime('now') WHERE id = ?",
138
+ ["Error: #{e.message}", task.id]
139
+ )
140
+ end
141
+
142
+ # Delegates to IdlePoller to wait for new work.
143
+ #
144
+ # @return [:resume, :shutdown, :interrupted]
145
+ def run_idle_phase
146
+ transition_to(:idle)
147
+
148
+ @idle_poller = IdlePoller.new(
149
+ mailbox: @mailbox,
150
+ task_manager: @task_manager,
151
+ agent_name: @agent_name,
152
+ poll_interval: @poll_interval,
153
+ idle_timeout: @idle_timeout
154
+ )
155
+
156
+ @idle_poller.poll!
157
+ end
158
+
159
+ # Performs final shutdown bookkeeping.
160
+ #
161
+ # @return [Symbol] :stopped
162
+ def shutdown!
163
+ transition_to(:shutting_down)
164
+ transition_to(:stopped)
165
+ @state
166
+ end
167
+
168
+ # @return [Boolean]
169
+ def safety_limit_reached?
170
+ return true if @runs_completed >= @max_runs
171
+ return true if @total_cost >= @max_cost
172
+
173
+ false
174
+ end
175
+
176
+ # Transitions the daemon to a new lifecycle state, invoking the
177
+ # optional callback.
178
+ #
179
+ # @param new_state [Symbol]
180
+ # @return [void]
181
+ def transition_to(new_state)
182
+ old_state = @state
183
+ @state = new_state
184
+ @on_state_change&.call(old_state, new_state)
185
+ end
186
+
187
+ # Accumulates cost from an LLM response.
188
+ #
189
+ # @param response [#usage] LLM response with usage data
190
+ # @return [void]
191
+ def track_cost(response)
192
+ return unless response.respond_to?(:usage) && response.usage.respond_to?(:cost)
193
+
194
+ @total_cost += response.usage.cost.to_f
195
+ end
196
+
197
+ # Extracts the textual result from an LLM response.
198
+ #
199
+ # @param response [#content] LLM response
200
+ # @return [String]
201
+ def extract_result(response)
202
+ return "" unless response.respond_to?(:content)
203
+
204
+ case response.content
205
+ when String
206
+ response.content
207
+ when Array
208
+ text_blocks = response.content.select { |b| b.is_a?(Hash) && b[:type] == "text" }
209
+ text_blocks.map { |b| b[:text] }.join("\n")
210
+ else
211
+ response.content.to_s
212
+ end
213
+ end
214
+
215
+ # @param task [Tasks::Task]
216
+ # @return [String]
217
+ def build_work_prompt(task)
218
+ "Execute the following task:\n\n" \
219
+ "Title: #{task.title}\n" \
220
+ "Description: #{task.description}\n" \
221
+ "Priority: #{task.priority}\n" \
222
+ "Task ID: #{task.id}"
223
+ end
224
+
225
+ # @return [String]
226
+ def build_system_prompt
227
+ "You are #{@agent_name}, an autonomous agent with the role: #{@role}. " \
228
+ "You are working on the project at #{@project_root}. " \
229
+ "Complete tasks thoroughly and report results clearly."
230
+ end
231
+ end
232
+ end
233
+ end