rubyn-code 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -467
  3. data/db/migrations/009_create_teams.sql +6 -6
  4. data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
  5. data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
  6. data/exe/rubyn-code +1 -1
  7. data/lib/rubyn_code/agent/RUBYN.md +17 -0
  8. data/lib/rubyn_code/agent/conversation.rb +68 -19
  9. data/lib/rubyn_code/agent/loop.rb +312 -54
  10. data/lib/rubyn_code/agent/loop_detector.rb +6 -6
  11. data/lib/rubyn_code/auth/RUBYN.md +19 -0
  12. data/lib/rubyn_code/auth/oauth.rb +40 -35
  13. data/lib/rubyn_code/auth/server.rb +16 -12
  14. data/lib/rubyn_code/auth/token_store.rb +22 -22
  15. data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
  16. data/lib/rubyn_code/autonomous/daemon.rb +115 -79
  17. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
  18. data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
  19. data/lib/rubyn_code/background/RUBYN.md +13 -0
  20. data/lib/rubyn_code/background/notifier.rb +0 -2
  21. data/lib/rubyn_code/background/worker.rb +60 -15
  22. data/lib/rubyn_code/cli/RUBYN.md +30 -0
  23. data/lib/rubyn_code/cli/app.rb +85 -9
  24. data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
  25. data/lib/rubyn_code/cli/commands/base.rb +53 -0
  26. data/lib/rubyn_code/cli/commands/budget.rb +24 -0
  27. data/lib/rubyn_code/cli/commands/clear.rb +16 -0
  28. data/lib/rubyn_code/cli/commands/compact.rb +21 -0
  29. data/lib/rubyn_code/cli/commands/context.rb +44 -0
  30. data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
  31. data/lib/rubyn_code/cli/commands/cost.rb +23 -0
  32. data/lib/rubyn_code/cli/commands/diff.rb +30 -0
  33. data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
  34. data/lib/rubyn_code/cli/commands/help.rb +41 -0
  35. data/lib/rubyn_code/cli/commands/model.rb +37 -0
  36. data/lib/rubyn_code/cli/commands/plan.rb +22 -0
  37. data/lib/rubyn_code/cli/commands/quit.rb +17 -0
  38. data/lib/rubyn_code/cli/commands/registry.rb +64 -0
  39. data/lib/rubyn_code/cli/commands/resume.rb +51 -0
  40. data/lib/rubyn_code/cli/commands/review.rb +26 -0
  41. data/lib/rubyn_code/cli/commands/skill.rb +32 -0
  42. data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
  43. data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
  44. data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
  45. data/lib/rubyn_code/cli/commands/undo.rb +17 -0
  46. data/lib/rubyn_code/cli/commands/version.rb +16 -0
  47. data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
  48. data/lib/rubyn_code/cli/input_handler.rb +20 -23
  49. data/lib/rubyn_code/cli/renderer.rb +25 -27
  50. data/lib/rubyn_code/cli/repl.rb +161 -194
  51. data/lib/rubyn_code/cli/setup.rb +117 -0
  52. data/lib/rubyn_code/cli/spinner.rb +40 -40
  53. data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
  54. data/lib/rubyn_code/cli/version_check.rb +94 -0
  55. data/lib/rubyn_code/config/RUBYN.md +14 -0
  56. data/lib/rubyn_code/config/defaults.rb +28 -19
  57. data/lib/rubyn_code/config/project_config.rb +7 -9
  58. data/lib/rubyn_code/config/settings.rb +3 -3
  59. data/lib/rubyn_code/context/RUBYN.md +20 -0
  60. data/lib/rubyn_code/context/auto_compact.rb +7 -7
  61. data/lib/rubyn_code/context/compactor.rb +2 -2
  62. data/lib/rubyn_code/context/context_collapse.rb +45 -0
  63. data/lib/rubyn_code/context/manager.rb +20 -3
  64. data/lib/rubyn_code/context/manual_compact.rb +7 -7
  65. data/lib/rubyn_code/context/micro_compact.rb +12 -12
  66. data/lib/rubyn_code/db/RUBYN.md +40 -0
  67. data/lib/rubyn_code/db/connection.rb +13 -13
  68. data/lib/rubyn_code/db/migrator.rb +67 -27
  69. data/lib/rubyn_code/db/schema.rb +6 -6
  70. data/lib/rubyn_code/debug.rb +74 -0
  71. data/lib/rubyn_code/hooks/RUBYN.md +17 -0
  72. data/lib/rubyn_code/hooks/built_in.rb +9 -9
  73. data/lib/rubyn_code/hooks/registry.rb +5 -5
  74. data/lib/rubyn_code/hooks/runner.rb +1 -1
  75. data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
  76. data/lib/rubyn_code/learning/RUBYN.md +16 -0
  77. data/lib/rubyn_code/learning/extractor.rb +22 -22
  78. data/lib/rubyn_code/learning/injector.rb +17 -18
  79. data/lib/rubyn_code/learning/instinct.rb +18 -14
  80. data/lib/rubyn_code/llm/RUBYN.md +15 -0
  81. data/lib/rubyn_code/llm/client.rb +121 -55
  82. data/lib/rubyn_code/llm/message_builder.rb +19 -15
  83. data/lib/rubyn_code/llm/streaming.rb +80 -50
  84. data/lib/rubyn_code/mcp/RUBYN.md +21 -0
  85. data/lib/rubyn_code/mcp/client.rb +25 -24
  86. data/lib/rubyn_code/mcp/config.rb +7 -7
  87. data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
  88. data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
  90. data/lib/rubyn_code/memory/RUBYN.md +17 -0
  91. data/lib/rubyn_code/memory/models.rb +3 -3
  92. data/lib/rubyn_code/memory/search.rb +17 -17
  93. data/lib/rubyn_code/memory/session_persistence.rb +49 -34
  94. data/lib/rubyn_code/memory/store.rb +17 -17
  95. data/lib/rubyn_code/observability/RUBYN.md +19 -0
  96. data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
  97. data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
  98. data/lib/rubyn_code/observability/token_counter.rb +1 -1
  99. data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
  100. data/lib/rubyn_code/output/RUBYN.md +11 -0
  101. data/lib/rubyn_code/output/diff_renderer.rb +6 -6
  102. data/lib/rubyn_code/output/formatter.rb +4 -4
  103. data/lib/rubyn_code/permissions/RUBYN.md +17 -0
  104. data/lib/rubyn_code/permissions/prompter.rb +8 -8
  105. data/lib/rubyn_code/protocols/RUBYN.md +14 -0
  106. data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
  107. data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
  108. data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
  109. data/lib/rubyn_code/skills/RUBYN.md +19 -0
  110. data/lib/rubyn_code/skills/catalog.rb +7 -7
  111. data/lib/rubyn_code/skills/document.rb +15 -15
  112. data/lib/rubyn_code/skills/loader.rb +6 -8
  113. data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
  114. data/lib/rubyn_code/sub_agents/runner.rb +15 -15
  115. data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
  116. data/lib/rubyn_code/tasks/RUBYN.md +13 -0
  117. data/lib/rubyn_code/tasks/dag.rb +12 -16
  118. data/lib/rubyn_code/tasks/manager.rb +24 -24
  119. data/lib/rubyn_code/tasks/models.rb +4 -4
  120. data/lib/rubyn_code/teams/RUBYN.md +14 -0
  121. data/lib/rubyn_code/teams/mailbox.rb +38 -18
  122. data/lib/rubyn_code/teams/manager.rb +19 -19
  123. data/lib/rubyn_code/teams/teammate.rb +3 -4
  124. data/lib/rubyn_code/tools/RUBYN.md +38 -0
  125. data/lib/rubyn_code/tools/background_run.rb +9 -11
  126. data/lib/rubyn_code/tools/base.rb +54 -3
  127. data/lib/rubyn_code/tools/bash.rb +16 -34
  128. data/lib/rubyn_code/tools/bundle_add.rb +10 -12
  129. data/lib/rubyn_code/tools/bundle_install.rb +9 -11
  130. data/lib/rubyn_code/tools/compact.rb +10 -9
  131. data/lib/rubyn_code/tools/db_migrate.rb +17 -15
  132. data/lib/rubyn_code/tools/edit_file.rb +12 -12
  133. data/lib/rubyn_code/tools/executor.rb +9 -4
  134. data/lib/rubyn_code/tools/git_commit.rb +29 -34
  135. data/lib/rubyn_code/tools/git_diff.rb +17 -18
  136. data/lib/rubyn_code/tools/git_log.rb +17 -19
  137. data/lib/rubyn_code/tools/git_status.rb +18 -20
  138. data/lib/rubyn_code/tools/glob.rb +7 -9
  139. data/lib/rubyn_code/tools/grep.rb +11 -9
  140. data/lib/rubyn_code/tools/load_skill.rb +7 -7
  141. data/lib/rubyn_code/tools/memory_search.rb +13 -12
  142. data/lib/rubyn_code/tools/memory_write.rb +14 -12
  143. data/lib/rubyn_code/tools/rails_generate.rb +16 -16
  144. data/lib/rubyn_code/tools/read_file.rb +8 -7
  145. data/lib/rubyn_code/tools/read_inbox.rb +5 -5
  146. data/lib/rubyn_code/tools/registry.rb +2 -2
  147. data/lib/rubyn_code/tools/review_pr.rb +55 -55
  148. data/lib/rubyn_code/tools/run_specs.rb +20 -19
  149. data/lib/rubyn_code/tools/schema.rb +9 -11
  150. data/lib/rubyn_code/tools/send_message.rb +10 -10
  151. data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
  152. data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
  153. data/lib/rubyn_code/tools/task.rb +28 -28
  154. data/lib/rubyn_code/tools/web_fetch.rb +46 -31
  155. data/lib/rubyn_code/tools/web_search.rb +64 -66
  156. data/lib/rubyn_code/tools/write_file.rb +7 -6
  157. data/lib/rubyn_code/version.rb +1 -1
  158. data/lib/rubyn_code.rb +136 -105
  159. metadata +94 -21
@@ -1,17 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
- require "digest"
5
- require "base64"
6
- require "faraday"
7
- require "json"
3
+ require 'securerandom'
4
+ require 'digest'
5
+ require 'base64'
6
+ require 'faraday'
7
+ require 'json'
8
8
 
9
9
  module RubynCode
10
10
  module Auth
11
11
  class OAuth
12
- StateMismatchError = Class.new(RubynCode::AuthenticationError)
13
- TokenExchangeError = Class.new(RubynCode::AuthenticationError)
14
- RefreshError = Class.new(RubynCode::AuthenticationError)
12
+ class StateMismatchError < RubynCode::AuthenticationError
13
+ end
14
+
15
+ class TokenExchangeError < RubynCode::AuthenticationError
16
+ end
17
+
18
+ class RefreshError < RubynCode::AuthenticationError
19
+ end
15
20
 
16
21
  VERIFIER_LENGTH = 43
17
22
 
@@ -28,7 +33,7 @@ module RubynCode
28
33
  result = callback_server.wait_for_callback(timeout: 120)
29
34
 
30
35
  unless secure_compare(result[:state], state)
31
- raise StateMismatchError, "OAuth state parameter mismatch — possible CSRF attack"
36
+ raise StateMismatchError, 'OAuth state parameter mismatch — possible CSRF attack'
32
37
  end
33
38
 
34
39
  tokens = exchange_code(code: result[:code], code_verifier:)
@@ -44,12 +49,12 @@ module RubynCode
44
49
 
45
50
  def refresh!
46
51
  stored = TokenStore.load
47
- raise RefreshError, "No stored refresh token available" unless stored&.dig(:refresh_token)
52
+ raise RefreshError, 'No stored refresh token available' unless stored&.dig(:refresh_token)
48
53
 
49
54
  response = http_client.post(token_url) do |req|
50
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
55
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
51
56
  req.body = URI.encode_www_form(
52
- grant_type: "refresh_token",
57
+ grant_type: 'refresh_token',
53
58
  client_id: client_id,
54
59
  refresh_token: stored[:refresh_token]
55
60
  )
@@ -57,23 +62,23 @@ module RubynCode
57
62
 
58
63
  unless response.success?
59
64
  body = parse_json(response.body)
60
- error_msg = body&.dig("error_description") || body&.dig("error") || response.body
65
+ error_msg = body&.dig('error_description') || body&.dig('error') || response.body
61
66
  raise RefreshError, "Token refresh failed (#{response.status}): #{error_msg}"
62
67
  end
63
68
 
64
69
  body = parse_json(response.body)
65
- raise RefreshError, "Invalid response from token endpoint" unless body
70
+ raise RefreshError, 'Invalid response from token endpoint' unless body
66
71
 
67
72
  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
73
+ access_token: body['access_token'],
74
+ refresh_token: body['refresh_token'] || stored[:refresh_token],
75
+ expires_at: Time.now + body['expires_in'].to_i
71
76
  )
72
77
 
73
78
  {
74
- access_token: body["access_token"],
75
- refresh_token: body["refresh_token"] || stored[:refresh_token],
76
- expires_in: body["expires_in"]
79
+ access_token: body['access_token'],
80
+ refresh_token: body['refresh_token'] || stored[:refresh_token],
81
+ expires_in: body['expires_in']
77
82
  }
78
83
  end
79
84
 
@@ -90,13 +95,13 @@ module RubynCode
90
95
 
91
96
  def build_authorization_url(code_challenge:, state:)
92
97
  params = URI.encode_www_form(
93
- response_type: "code",
98
+ response_type: 'code',
94
99
  client_id: client_id,
95
100
  redirect_uri: redirect_uri,
96
101
  scope: scopes,
97
102
  state: state,
98
103
  code_challenge: code_challenge,
99
- code_challenge_method: "S256"
104
+ code_challenge_method: 'S256'
100
105
  )
101
106
 
102
107
  "#{authorize_url}?#{params}"
@@ -104,9 +109,9 @@ module RubynCode
104
109
 
105
110
  def exchange_code(code:, code_verifier:)
106
111
  response = http_client.post(token_url) do |req|
107
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
112
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
108
113
  req.body = URI.encode_www_form(
109
- grant_type: "authorization_code",
114
+ grant_type: 'authorization_code',
110
115
  client_id: client_id,
111
116
  code: code,
112
117
  redirect_uri: redirect_uri,
@@ -116,26 +121,26 @@ module RubynCode
116
121
 
117
122
  unless response.success?
118
123
  body = parse_json(response.body)
119
- error_msg = body&.dig("error_description") || body&.dig("error") || response.body
124
+ error_msg = body&.dig('error_description') || body&.dig('error') || response.body
120
125
  raise TokenExchangeError, "Code exchange failed (#{response.status}): #{error_msg}"
121
126
  end
122
127
 
123
128
  body = parse_json(response.body)
124
- raise TokenExchangeError, "Invalid response from token endpoint" unless body
129
+ raise TokenExchangeError, 'Invalid response from token endpoint' unless body
125
130
 
126
131
  {
127
- access_token: body["access_token"],
128
- refresh_token: body["refresh_token"],
129
- expires_in: body["expires_in"]
132
+ access_token: body['access_token'],
133
+ refresh_token: body['refresh_token'],
134
+ expires_in: body['expires_in']
130
135
  }
131
136
  end
132
137
 
133
138
  def open_browser(url)
134
139
  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"
140
+ when /darwin/ then 'open'
141
+ when /linux/ then 'xdg-open'
142
+ when /mingw|mswin/ then 'start'
143
+ else 'xdg-open'
139
144
  end
140
145
 
141
146
  system(launcher, url, exception: false)
@@ -159,8 +164,8 @@ module RubynCode
159
164
  return false if a.nil? || b.nil?
160
165
  return false unless a.bytesize == b.bytesize
161
166
 
162
- l = a.unpack("C*")
163
- r = b.unpack("C*")
167
+ l = a.unpack('C*')
168
+ r = b.unpack('C*')
164
169
  l.zip(r).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
165
170
  end
166
171
 
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "webrick"
4
- require "uri"
3
+ require 'webrick'
4
+ require 'uri'
5
5
 
6
6
  module RubynCode
7
7
  module Auth
8
8
  class Server
9
- LISTEN_HOST = "127.0.0.1"
9
+ LISTEN_HOST = '127.0.0.1'
10
10
  LISTEN_PORT = 19_275
11
11
 
12
- CallbackTimeout = Class.new(RubynCode::AuthenticationError)
12
+ class CallbackTimeout < RubynCode::AuthenticationError
13
+ end
13
14
 
14
15
  def initialize
15
16
  @result = nil
@@ -46,7 +47,7 @@ module RubynCode
46
47
  AccessLog: access_log
47
48
  )
48
49
 
49
- server.mount_proc("/callback") do |req, res|
50
+ server.mount_proc('/callback') do |req, res|
50
51
  handle_callback(req, res, server)
51
52
  end
52
53
 
@@ -55,8 +56,8 @@ module RubynCode
55
56
 
56
57
  def handle_callback(req, res, server)
57
58
  params = parse_query(req.query_string)
58
- code = params["code"]
59
- state = params["state"]
59
+ code = params['code']
60
+ state = params['state']
60
61
 
61
62
  if code
62
63
  @mutex.synchronize do
@@ -65,14 +66,14 @@ module RubynCode
65
66
  end
66
67
 
67
68
  res.status = 200
68
- res.content_type = "text/html; charset=utf-8"
69
+ res.content_type = 'text/html; charset=utf-8'
69
70
  res.body = success_html
70
71
  else
71
- error = params["error"] || "unknown"
72
- description = params["error_description"] || "No authorization code received"
72
+ error = params['error'] || 'unknown'
73
+ description = params['error_description'] || 'No authorization code received'
73
74
 
74
75
  res.status = 400
75
- res.content_type = "text/html; charset=utf-8"
76
+ res.content_type = 'text/html; charset=utf-8'
76
77
  res.body = error_html(error, description)
77
78
 
78
79
  @mutex.synchronize do
@@ -80,7 +81,10 @@ module RubynCode
80
81
  end
81
82
  end
82
83
 
83
- Thread.new { sleep(0.5); server.shutdown }
84
+ Thread.new do
85
+ sleep(0.5)
86
+ server.shutdown
87
+ end
84
88
  end
85
89
 
86
90
  def parse_query(query_string)
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "fileutils"
5
- require "json"
6
- require "time"
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'time'
7
7
 
8
8
  module RubynCode
9
9
  module Auth
10
10
  module TokenStore
11
11
  EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
12
- KEYCHAIN_SERVICE = "Claude Code-credentials"
12
+ KEYCHAIN_SERVICE = 'Claude Code-credentials'
13
13
 
14
14
  class << self
15
15
  # Load tokens with fallback chain:
@@ -24,9 +24,9 @@ module RubynCode
24
24
  ensure_directory!
25
25
 
26
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
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
30
  }
31
31
 
32
32
  File.write(tokens_path, YAML.dump(data))
@@ -35,7 +35,7 @@ module RubynCode
35
35
  end
36
36
 
37
37
  def clear!
38
- File.delete(tokens_path) if File.exist?(tokens_path)
38
+ FileUtils.rm_f(tokens_path)
39
39
  true
40
40
  end
41
41
 
@@ -71,22 +71,22 @@ module RubynCode
71
71
 
72
72
  # Read Claude Code's OAuth token from macOS Keychain
73
73
  def load_from_keychain
74
- return nil unless RUBY_PLATFORM.include?("darwin")
74
+ return nil unless RUBY_PLATFORM.include?('darwin')
75
75
 
76
76
  output = `security find-generic-password -s "#{KEYCHAIN_SERVICE}" -w 2>/dev/null`.strip
77
77
  return nil if output.empty?
78
78
 
79
79
  data = JSON.parse(output)
80
- oauth = data["claudeAiOauth"]
81
- return nil unless oauth && oauth["accessToken"]
80
+ oauth = data['claudeAiOauth']
81
+ return nil unless oauth && oauth['accessToken']
82
82
 
83
- expires_at = if oauth["expiresAt"]
84
- Time.at(oauth["expiresAt"] / 1000.0) # milliseconds to seconds
83
+ expires_at = if oauth['expiresAt']
84
+ Time.at(oauth['expiresAt'] / 1000.0) # milliseconds to seconds
85
85
  end
86
86
 
87
87
  {
88
- access_token: oauth["accessToken"],
89
- refresh_token: oauth["refreshToken"],
88
+ access_token: oauth['accessToken'],
89
+ refresh_token: oauth['refreshToken'],
90
90
  expires_at: expires_at,
91
91
  type: :oauth,
92
92
  source: :keychain
@@ -101,12 +101,12 @@ module RubynCode
101
101
 
102
102
  data = YAML.safe_load_file(tokens_path, permitted_classes: [Time])
103
103
  return nil unless data.is_a?(Hash)
104
- return nil unless data["access_token"]
104
+ return nil unless data['access_token']
105
105
 
106
106
  {
107
- access_token: data["access_token"],
108
- refresh_token: data["refresh_token"],
109
- expires_at: parse_time(data["expires_at"]),
107
+ access_token: data['access_token'],
108
+ refresh_token: data['refresh_token'],
109
+ expires_at: parse_time(data['expires_at']),
110
110
  type: :oauth,
111
111
  source: :file
112
112
  }
@@ -116,7 +116,7 @@ module RubynCode
116
116
 
117
117
  # Fall back to ANTHROPIC_API_KEY environment variable
118
118
  def load_from_env
119
- api_key = ENV["ANTHROPIC_API_KEY"]
119
+ api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
120
120
  return nil unless api_key && !api_key.empty?
121
121
 
122
122
  {
@@ -134,7 +134,7 @@ module RubynCode
134
134
 
135
135
  def ensure_directory!
136
136
  dir = File.dirname(tokens_path)
137
- FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
137
+ FileUtils.mkdir_p(dir)
138
138
  File.chmod(0o700, dir)
139
139
  end
140
140
 
@@ -0,0 +1,14 @@
1
+ # Layer 11: Autonomous
2
+
3
+ Daemon mode for hands-off task execution.
4
+
5
+ ## Classes
6
+
7
+ - **`Daemon`** — Runs the agent in background mode. Polls for unclaimed tasks,
8
+ executes them, and reports results. No human in the loop.
9
+
10
+ - **`IdlePoller`** — Watches for new tasks at a configurable interval. Wakes the
11
+ daemon when work is available.
12
+
13
+ - **`TaskClaimer`** — Atomically claims tasks from the DAG to prevent double-execution
14
+ when multiple agents are running.
@@ -1,15 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
3
+ require 'securerandom'
4
4
 
5
5
  module RubynCode
6
6
  module Autonomous
7
- # The KAIROS daemon -- an always-on autonomous agent that cycles between
7
+ # The GOLEM daemon an always-on autonomous agent that cycles between
8
8
  # working on tasks and polling for new work. The lifecycle is:
9
9
  #
10
- # spawn -> work -> idle -> work -> ... -> shutdown
10
+ # spawned working idle shutting_down stopped
11
11
  #
12
- # Safety limits (max_runs, max_cost) prevent runaway execution.
12
+ # Safety limits (max_runs, max_cost, idle_timeout) prevent runaway execution.
13
+ # Signal traps (SIGTERM, SIGINT) trigger graceful shutdown.
14
+ #
15
+ # Unlike the REPL, the daemon runs a full Agent::Loop per task — meaning
16
+ # it can read files, write code, run specs, and use every tool available.
13
17
  class Daemon
14
18
  LIFECYCLE_STATES = %i[spawned working idle shutting_down stopped].freeze
15
19
 
@@ -19,32 +23,38 @@ module RubynCode
19
23
  # @param role [String] the agent's role / persona description
20
24
  # @param llm_client [LLM::Client] LLM API client
21
25
  # @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
26
+ # @param task_manager [Tasks::Manager] task persistence layer
27
+ # @param mailbox [Teams::Mailbox] message mailbox
24
28
  # @param max_runs [Integer] maximum work cycles before auto-shutdown (default 100)
25
29
  # @param max_cost [Float] maximum cumulative LLM cost in USD before auto-shutdown (default 10.0)
26
30
  # @param poll_interval [Numeric] idle polling interval in seconds (default 5)
27
31
  # @param idle_timeout [Numeric] seconds of idle before shutdown (default 60)
28
32
  # @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
33
+ # @param on_task_complete [Proc, nil] callback invoked with (task, result_text)
34
+ # @param on_task_error [Proc, nil] callback invoked with (task, error)
35
+ def initialize( # rubocop:disable Metrics/ParameterLists
36
+ agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:,
37
+ max_runs: 100, max_cost: 10.0, poll_interval: 5, idle_timeout: 60,
38
+ on_state_change: nil, on_task_complete: nil, on_task_error: nil
39
+ )
40
+ @agent_name = agent_name
41
+ @role = role
42
+ @llm_client = llm_client
43
+ @project_root = File.expand_path(project_root)
44
+ @task_manager = task_manager
45
+ @mailbox = mailbox
46
+ @max_runs = max_runs
47
+ @max_cost = max_cost
48
+ @poll_interval = poll_interval
49
+ @idle_timeout = idle_timeout
42
50
  @on_state_change = on_state_change
51
+ @on_task_complete = on_task_complete
52
+ @on_task_error = on_task_error
43
53
 
44
- @state = :spawned
45
- @runs_completed = 0
46
- @total_cost = 0.0
47
- @stop_requested = false
54
+ @state = :spawned
55
+ @runs_completed = 0
56
+ @total_cost = 0.0
57
+ @stop_requested = false
48
58
  end
49
59
 
50
60
  # Enters the work-idle-work cycle. Blocks the calling thread until
@@ -52,6 +62,7 @@ module RubynCode
52
62
  #
53
63
  # @return [Symbol] the final state (:stopped)
54
64
  def start!
65
+ install_signal_handlers!
55
66
  transition_to(:working)
56
67
 
57
68
  loop do
@@ -108,37 +119,85 @@ module RubynCode
108
119
 
109
120
  private
110
121
 
111
- # Executes the agent loop for a single claimed task.
122
+ # ── Signal handling ──────────────────────────────────────────
123
+
124
+ def install_signal_handlers!
125
+ %w[INT TERM].each do |sig|
126
+ Signal.trap(sig) { stop! }
127
+ end
128
+ rescue ArgumentError
129
+ # Some signals not available on all platforms (e.g. Windows)
130
+ end
131
+
132
+ # ── Work phase (full Agent::Loop) ────────────────────────────
133
+
134
+ # Executes a full agent loop for a single claimed task — with tools,
135
+ # context management, and budget enforcement.
112
136
  #
113
137
  # @param task [Tasks::Task]
114
138
  # @return [void]
115
139
  def run_work_phase(task)
116
140
  transition_to(:working)
117
141
 
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
- )
142
+ agent_loop = build_agent_loop
143
+ result_text = agent_loop.send_message(build_work_prompt(task))
125
144
 
126
- track_cost(response)
145
+ # Accumulate cost from the budget enforcer
146
+ track_cost_from_enforcer(agent_loop)
127
147
 
128
148
  # 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
- )
149
+ @task_manager.complete(task.id, result: result_text)
150
+ @on_task_complete&.call(task, result_text)
134
151
  rescue StandardError => e
135
152
  # 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]
153
+ @task_manager.update(task.id, status: 'pending', owner: nil, result: "Error: #{e.message}")
154
+ @on_task_error&.call(task, e)
155
+ end
156
+
157
+ # Builds a fresh Agent::Loop wired with all the real tools.
158
+ # Each task gets its own conversation and context so they don't bleed.
159
+ #
160
+ # @return [Agent::Loop]
161
+ def build_agent_loop
162
+ conversation = Agent::Conversation.new
163
+ tool_executor = Tools::Executor.new(project_root: @project_root)
164
+ context_manager = Context::Manager.new
165
+ hook_runner = Hooks::Runner.new(registry: Hooks::Registry.new)
166
+ stall_detector = Agent::LoopDetector.new
167
+
168
+ # Wire dependencies the executor needs for sub-agents / background
169
+ tool_executor.llm_client = @llm_client
170
+ tool_executor.db = @task_manager.db
171
+
172
+ Agent::Loop.new(
173
+ llm_client: @llm_client,
174
+ tool_executor: tool_executor,
175
+ context_manager: context_manager,
176
+ hook_runner: hook_runner,
177
+ conversation: conversation,
178
+ permission_tier: :unrestricted,
179
+ stall_detector: stall_detector,
180
+ project_root: @project_root
139
181
  )
140
182
  end
141
183
 
184
+ # Accumulates cost tracked by the Agent::Loop's context manager.
185
+ #
186
+ # @param agent_loop [Agent::Loop]
187
+ # @return [void]
188
+ def track_cost_from_enforcer(agent_loop)
189
+ # The context manager tracks token usage; we extract cost if available.
190
+ # This is best-effort — the daemon's own total_cost is an approximation.
191
+ cm = agent_loop.instance_variable_get(:@context_manager)
192
+ return unless cm.respond_to?(:total_cost)
193
+
194
+ @total_cost += cm.total_cost.to_f
195
+ rescue StandardError
196
+ # Non-critical
197
+ end
198
+
199
+ # ── Idle phase ───────────────────────────────────────────────
200
+
142
201
  # Delegates to IdlePoller to wait for new work.
143
202
  #
144
203
  # @return [:resume, :shutdown, :interrupted]
@@ -156,6 +215,8 @@ module RubynCode
156
215
  @idle_poller.poll!
157
216
  end
158
217
 
218
+ # ── Lifecycle ────────────────────────────────────────────────
219
+
159
220
  # Performs final shutdown bookkeeping.
160
221
  #
161
222
  # @return [Symbol] :stopped
@@ -184,49 +245,24 @@ module RubynCode
184
245
  @on_state_change&.call(old_state, new_state)
185
246
  end
186
247
 
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
248
+ # ── Prompts ──────────────────────────────────────────────────
214
249
 
215
250
  # @param task [Tasks::Task]
216
251
  # @return [String]
217
252
  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."
253
+ <<~PROMPT
254
+ You are working autonomously as daemon agent "#{@agent_name}".
255
+ Complete the following task using the tools available to you.
256
+
257
+ Title: #{task.title}
258
+ Description: #{task.description}
259
+ Priority: #{task.priority}
260
+ Task ID: #{task.id}
261
+
262
+ Work in the project at: #{@project_root}
263
+ Be thorough. Use tools to read, write, test, and verify your work.
264
+ When done, summarize what you did.
265
+ PROMPT
230
266
  end
231
267
  end
232
268
  end
@@ -35,13 +35,9 @@ module RubynCode
35
35
  return :shutdown if monotonic_now >= deadline
36
36
 
37
37
  # Messages always take priority over tasks.
38
- if has_pending_messages?
39
- return :resume
40
- end
38
+ return :resume if has_pending_messages?
41
39
 
42
- if has_claimable_task?
43
- return :resume
44
- end
40
+ return :resume if has_claimable_task?
45
41
 
46
42
  remaining = deadline - monotonic_now
47
43
  return :shutdown if remaining <= 0
@@ -71,10 +67,10 @@ module RubynCode
71
67
 
72
68
  # Only re-inject if the identity is not already present as the
73
69
  # first user message.
74
- first_user = messages.find { |m| m[:role] == "user" }
70
+ first_user = messages.find { |m| m[:role] == 'user' }
75
71
  return if first_user && first_user[:content].to_s.include?(identity[0, 100])
76
72
 
77
- messages.unshift({ role: "user", content: identity })
73
+ messages.unshift({ role: 'user', content: identity })
78
74
  end
79
75
 
80
76
  private