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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- 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
|