rubyn-code 0.3.0 → 0.5.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +263 -21
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +34 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +11 -1
  7. data/lib/rubyn_code/agent/loop.rb +14 -3
  8. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
  10. data/lib/rubyn_code/agent/tool_processor.rb +25 -3
  11. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  12. data/lib/rubyn_code/auth/token_store.rb +50 -9
  13. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  14. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  15. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  16. data/lib/rubyn_code/cli/app.rb +116 -11
  17. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  18. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  19. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  20. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  21. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  22. data/lib/rubyn_code/cli/commands/provider.rb +124 -0
  23. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  24. data/lib/rubyn_code/cli/commands/skill.rb +54 -3
  25. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  27. data/lib/rubyn_code/cli/first_run.rb +159 -0
  28. data/lib/rubyn_code/cli/repl.rb +15 -0
  29. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +74 -1
  32. data/lib/rubyn_code/config/defaults.rb +3 -0
  33. data/lib/rubyn_code/config/schema.json +49 -0
  34. data/lib/rubyn_code/config/settings.rb +12 -6
  35. data/lib/rubyn_code/config/validator.rb +63 -0
  36. data/lib/rubyn_code/context/context_budget.rb +18 -2
  37. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  38. data/lib/rubyn_code/context/manager.rb +37 -3
  39. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  40. data/lib/rubyn_code/hooks/registry.rb +4 -0
  41. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  42. data/lib/rubyn_code/ide/client.rb +110 -0
  43. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  44. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  45. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  46. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  47. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  48. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  49. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  50. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
  51. data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
  52. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  53. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  54. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  55. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  56. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  57. data/lib/rubyn_code/ide/handlers.rb +76 -0
  58. data/lib/rubyn_code/ide/protocol.rb +112 -0
  59. data/lib/rubyn_code/ide/server.rb +186 -0
  60. data/lib/rubyn_code/index/codebase_index.rb +69 -2
  61. data/lib/rubyn_code/learning/extractor.rb +4 -2
  62. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  63. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  64. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  65. data/lib/rubyn_code/llm/client.rb +29 -4
  66. data/lib/rubyn_code/llm/model_router.rb +2 -1
  67. data/lib/rubyn_code/mcp/config.rb +2 -1
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  69. data/lib/rubyn_code/memory/search.rb +1 -0
  70. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  71. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  72. data/lib/rubyn_code/self_test.rb +316 -0
  73. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  74. data/lib/rubyn_code/skills/catalog.rb +76 -0
  75. data/lib/rubyn_code/skills/document.rb +8 -2
  76. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  77. data/lib/rubyn_code/skills/loader.rb +43 -0
  78. data/lib/rubyn_code/skills/matcher.rb +89 -0
  79. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  80. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  81. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  82. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  83. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  84. data/lib/rubyn_code/tasks/models.rb +1 -0
  85. data/lib/rubyn_code/tools/base.rb +13 -0
  86. data/lib/rubyn_code/tools/bash.rb +5 -0
  87. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  88. data/lib/rubyn_code/tools/executor.rb +65 -8
  89. data/lib/rubyn_code/tools/glob.rb +6 -0
  90. data/lib/rubyn_code/tools/grep.rb +7 -0
  91. data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
  92. data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
  93. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  94. data/lib/rubyn_code/tools/output_compressor.rb +9 -7
  95. data/lib/rubyn_code/tools/read_file.rb +6 -0
  96. data/lib/rubyn_code/tools/registry.rb +11 -0
  97. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  98. data/lib/rubyn_code/tools/web_search.rb +2 -1
  99. data/lib/rubyn_code/tools/write_file.rb +17 -0
  100. data/lib/rubyn_code/version.rb +1 -1
  101. data/lib/rubyn_code.rb +34 -0
  102. data/skills/rubyn_self_test.md +88 -1
  103. metadata +43 -1
@@ -44,6 +44,9 @@ module RubynCode
44
44
  # @return [Boolean]
45
45
  attr_accessor :plan_mode
46
46
 
47
+ # @return [Index::CodebaseIndex, nil]
48
+ attr_reader :codebase_index
49
+
47
50
  # Send a user message and run the agent loop until a final text
48
51
  # response is produced or the iteration limit is reached.
49
52
  #
@@ -56,6 +59,7 @@ module RubynCode
56
59
  inject_skill_listing unless @skills_injected
57
60
  @decision_compactor&.detect_topic_switch(user_input)
58
61
  @skill_ttl&.tick!
62
+ autoload_triggered_skills(user_input)
59
63
  @conversation.add_user_message(user_input)
60
64
  reset_iteration_state
61
65
 
@@ -90,7 +94,10 @@ module RubynCode
90
94
  @background_manager = opts[:background_manager]
91
95
  @stall_detector = opts.fetch(:stall_detector, LoopDetector.new)
92
96
  @skill_loader = opts[:skill_loader]
97
+ @skill_matcher = opts[:skill_matcher]
98
+ @web_skill_autoload = opts[:web_skill_autoload]
93
99
  @project_root = opts[:project_root]
100
+ @tool_wrapper = opts[:tool_wrapper]
94
101
  @decision_compactor = build_decision_compactor
95
102
  @skill_ttl = Skills::TtlManager.new
96
103
  @session_initialized = false
@@ -123,15 +130,17 @@ module RubynCode
123
130
  def build_codebase_index!
124
131
  index = Index::CodebaseIndex.new(project_root: @project_root)
125
132
  index.load_or_build!
133
+ @codebase_index = index
126
134
  RubynCode::Debug.agent("Codebase index: #{index.stats[:nodes]} nodes, #{index.stats[:files_indexed]} files")
127
135
  rescue StandardError => e
128
136
  RubynCode::Debug.warn("Codebase index failed: #{e.message}")
129
137
  end
130
138
 
131
139
  def assign_callbacks(opts)
132
- @on_tool_call = opts[:on_tool_call]
133
- @on_tool_result = opts[:on_tool_result]
134
- @on_text = opts[:on_text]
140
+ @on_tool_call = opts[:on_tool_call]
141
+ @on_tool_result = opts[:on_tool_result]
142
+ @on_text = opts[:on_text]
143
+ @on_skills_autoloaded = opts[:on_skills_autoloaded]
135
144
  @skills_injected = false
136
145
  end
137
146
 
@@ -143,6 +152,7 @@ module RubynCode
143
152
 
144
153
  def run_iteration(iteration)
145
154
  log_iteration(iteration)
155
+ @context_manager.advance_turn!
146
156
  compact_if_needed # ensure context is under threshold before LLM call
147
157
  response = call_llm
148
158
  tool_calls = extract_tool_calls(response)
@@ -224,6 +234,7 @@ module RubynCode
224
234
  @conversation.add_assistant_message(get_content(response))
225
235
  process_tool_calls(tool_calls)
226
236
  drain_background_notifications
237
+ @decision_compactor&.check!(@conversation)
227
238
  run_maintenance(iteration)
228
239
  nil
229
240
  end
@@ -45,7 +45,8 @@ module RubynCode
45
45
  # @param message [String] the user's input
46
46
  # @param tool_calls [Array] recent tool calls (for context)
47
47
  # @return [Symbol] one of the MODES keys
48
- def detect(message, tool_calls: []) # rubocop:disable Metrics/CyclomaticComplexity -- mode detection dispatch
48
+ # -- mode detection dispatch
49
+ def detect(message, tool_calls: [])
49
50
  return :implementing if implementation_signal?(message)
50
51
  return :debugging if debugging_signal?(message)
51
52
  return :reviewing if reviewing_signal?(message)
@@ -64,15 +64,21 @@ module RubynCode
64
64
  def append_codebase_index(parts)
65
65
  return unless @project_root
66
66
 
67
- index = Index::CodebaseIndex.new(project_root: @project_root)
68
- loaded = index.load
69
- return unless loaded && index.nodes.any?
67
+ index = resolve_codebase_index
68
+ return unless index&.nodes&.any?
70
69
 
71
- parts << "\n## #{index.to_prompt_summary}"
70
+ parts << "\n## #{index.to_structural_summary}"
72
71
  rescue StandardError
73
72
  nil
74
73
  end
75
74
 
75
+ def resolve_codebase_index
76
+ return @codebase_index if defined?(@codebase_index) && @codebase_index
77
+
78
+ idx = Index::CodebaseIndex.new(project_root: @project_root)
79
+ idx.load
80
+ end
81
+
76
82
  def append_memories(parts)
77
83
  memories = load_memories
78
84
  return if memories.empty?
@@ -117,6 +123,45 @@ module RubynCode
117
123
  @skills_injected = true
118
124
  end
119
125
 
126
+ # Match the current user message against every skill's :triggers and
127
+ # inject the body of any new match into the conversation so the LLM sees
128
+ # it on the next call. Per-session dedup lives in the Matcher.
129
+ #
130
+ # When the message matches a registry pack the user hasn't installed,
131
+ # @web_skill_autoload silently fetches it, installs it, refreshes the
132
+ # catalog, and surfaces any new skill matches. Web fallback failures
133
+ # are silent so the turn proceeds normally.
134
+ def autoload_triggered_skills(user_input)
135
+ return unless @skill_matcher && @skill_loader
136
+
137
+ matches = @skill_matcher.match(user_input)
138
+ matches += @web_skill_autoload.try(user_input) if @web_skill_autoload
139
+ return if matches.empty?
140
+
141
+ names = matches.map { |m| m[:name] }
142
+ bodies = names.filter_map do |name|
143
+ @skill_loader.load(name)
144
+ rescue StandardError => e
145
+ RubynCode::Debug.warn("Failed to autoload skill '#{name}': #{e.message}")
146
+ nil
147
+ end
148
+ return if bodies.empty?
149
+
150
+ inject_autoloaded_bodies(bodies)
151
+ @on_skills_autoloaded&.call(names)
152
+ end
153
+
154
+ def inject_autoloaded_bodies(bodies)
155
+ @conversation.add_user_message(
156
+ '[system] The following skills are auto-loaded based on the next user ' \
157
+ "message's triggers. Use them as context. Do not mention this message " \
158
+ "to the user.\n\n#{bodies.join("\n\n")}"
159
+ )
160
+ @conversation.add_assistant_message(
161
+ [{ type: 'text', text: 'Understood.' }]
162
+ )
163
+ end
164
+
120
165
  def append_deferred_tools(parts)
121
166
  deferred = deferred_tool_names
122
167
  return if deferred.empty?
@@ -26,7 +26,8 @@ module RubynCode
26
26
  all_tools.select { |t| core_or_discovered?(t) }
27
27
  end
28
28
 
29
- def detect_task_context # rubocop:disable Metrics/CyclomaticComplexity -- safe navigation chain
29
+ # -- safe navigation chain
30
+ def detect_task_context
30
31
  last_msg = @conversation&.messages&.reverse_each&.find { |m| m[:role] == 'user' } # rubocop:disable Style/SafeNavigationChainLength
31
32
  return nil unless last_msg
32
33
 
@@ -51,6 +52,7 @@ module RubynCode
51
52
  Tools::Registry.all.select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }.map(&:to_schema)
52
53
  end
53
54
 
55
+ # -- tool dispatch with budget + signals
54
56
  def process_tool_calls(tool_calls)
55
57
  aggregate_chars = 0
56
58
  budget = Config::Defaults::MAX_MESSAGE_TOOL_RESULTS_CHARS
@@ -62,6 +64,7 @@ module RubynCode
62
64
  notify_tool_result(field(tool_call, :name), result, is_error)
63
65
  record_tool_result(tool_call, result, is_error)
64
66
  end
67
+ @decision_compactor&.signal_edit_batch_complete!
65
68
  end
66
69
 
67
70
  def run_single_tool(tool_call)
@@ -114,15 +117,34 @@ module RubynCode
114
117
  def execute_tool(tool_name, tool_input)
115
118
  discover_tool(tool_name)
116
119
  @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
117
- result = @tool_executor.execute(tool_name, symbolize_keys(tool_input))
120
+ result = dispatch_tool(tool_name, tool_input)
118
121
  @hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
119
122
  signal_decision_compactor(tool_name, tool_input, result)
120
123
  [result.to_s, false]
124
+ rescue RubynCode::UserDeniedError => e
125
+ # User refused this call via the IDE. Surface as is_error so the model
126
+ # knows the tool did not run, not that it ran and returned text.
127
+ [e.message, true]
121
128
  rescue StandardError => e
122
129
  ["Error executing #{tool_name}: #{e.message}", true]
123
130
  end
124
131
 
125
- def signal_decision_compactor(tool_name, tool_input, result) # rubocop:disable Metrics/CyclomaticComplexity -- tool dispatch
132
+ # Run the tool through @tool_wrapper if one is configured (IDE mode),
133
+ # otherwise call the executor directly. The wrapper receives the raw
134
+ # tool name/input so it can emit protocol notifications and gate the
135
+ # call; the block below is what actually performs the work.
136
+ def dispatch_tool(tool_name, tool_input)
137
+ if @tool_wrapper
138
+ @tool_wrapper.call(tool_name, tool_input) do
139
+ @tool_executor.execute(tool_name, symbolize_keys(tool_input))
140
+ end
141
+ else
142
+ @tool_executor.execute(tool_name, symbolize_keys(tool_input))
143
+ end
144
+ end
145
+
146
+ # -- tool dispatch
147
+ def signal_decision_compactor(tool_name, tool_input, result)
126
148
  return unless @decision_compactor
127
149
 
128
150
  case tool_name
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'securerandom'
6
+ require 'etc'
7
+ require 'socket'
8
+
9
+ module RubynCode
10
+ module Auth
11
+ # Encrypts and decrypts provider API keys at rest using AES-256-GCM.
12
+ #
13
+ # The encryption key is derived via PBKDF2 from machine-specific identifiers
14
+ # (username, hostname, home directory) combined with a random salt stored in
15
+ # ~/.rubyn-code/.encryption_salt. This means keys are only decryptable on the
16
+ # same machine by the same user.
17
+ #
18
+ # Encrypted values are prefixed with "enc:v1:" so plaintext values from older
19
+ # versions are transparently migrated on first read.
20
+ module KeyEncryption
21
+ CIPHER = 'aes-256-gcm'
22
+ PREFIX = 'enc:v1:'
23
+ IV_LENGTH = 12
24
+ TAG_LENGTH = 16
25
+ PBKDF2_ITERATIONS = 100_000
26
+ KEY_LENGTH = 32
27
+ SALT_LENGTH = 32
28
+
29
+ class << self
30
+ def encrypt(plaintext)
31
+ return nil unless plaintext
32
+
33
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
34
+ key = derive_key
35
+ cipher.key = key
36
+ iv = cipher.random_iv
37
+
38
+ ciphertext = cipher.update(plaintext) + cipher.final
39
+ tag = cipher.auth_tag(TAG_LENGTH)
40
+
41
+ encoded = Base64.strict_encode64(iv + ciphertext + tag)
42
+ "#{PREFIX}#{encoded}"
43
+ end
44
+
45
+ def decrypt(value)
46
+ return nil unless value
47
+ return value unless encrypted?(value)
48
+
49
+ raw = Base64.strict_decode64(value.delete_prefix(PREFIX))
50
+ decrypt_raw(raw)
51
+ rescue OpenSSL::Cipher::CipherError, ArgumentError
52
+ nil
53
+ end
54
+
55
+ def encrypted?(value)
56
+ value.is_a?(String) && value.start_with?(PREFIX)
57
+ end
58
+
59
+ private
60
+
61
+ def decrypt_raw(raw)
62
+ iv = raw[0, IV_LENGTH]
63
+ tag = raw[-TAG_LENGTH, TAG_LENGTH]
64
+ ciphertext = raw[IV_LENGTH...-TAG_LENGTH]
65
+
66
+ cipher = OpenSSL::Cipher.new(CIPHER).decrypt
67
+ cipher.key = derive_key
68
+ cipher.iv = iv
69
+ cipher.auth_tag = tag
70
+ (cipher.update(ciphertext) + cipher.final).force_encoding('UTF-8')
71
+ end
72
+
73
+ def derive_key
74
+ OpenSSL::KDF.pbkdf2_hmac(
75
+ machine_identity,
76
+ salt: load_or_create_salt,
77
+ iterations: PBKDF2_ITERATIONS,
78
+ length: KEY_LENGTH,
79
+ hash: 'SHA256'
80
+ )
81
+ end
82
+
83
+ def machine_identity
84
+ # Use the real UID's login name rather than Etc.getlogin. Etc.getlogin
85
+ # reads the controlling tty's owner and can return "root" when the tty
86
+ # is root-owned (common after `sudo`, and in some VSCode integrated
87
+ # terminal setups) — even though the process itself is running as the
88
+ # real user. That mismatch derives a different AES key on decrypt vs.
89
+ # encrypt and the AEAD tag check fails, which surfaces as a misleading
90
+ # "No <provider> API key configured" error.
91
+ user = begin
92
+ Etc.getpwuid(Process.uid).name
93
+ rescue StandardError
94
+ ENV['USER'] || Etc.getlogin || 'unknown'
95
+ end
96
+ [user, Socket.gethostname, Dir.home].join(':')
97
+ end
98
+
99
+ def load_or_create_salt
100
+ path = salt_path
101
+ if File.exist?(path)
102
+ File.binread(path)
103
+ else
104
+ salt = SecureRandom.random_bytes(SALT_LENGTH)
105
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
106
+ File.binwrite(path, salt)
107
+ File.chmod(0o600, path)
108
+ salt
109
+ end
110
+ end
111
+
112
+ def salt_path
113
+ File.join(Config::Defaults::HOME_DIR, '.encryption_salt')
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -7,7 +7,7 @@ require 'time'
7
7
 
8
8
  module RubynCode
9
9
  module Auth
10
- module TokenStore
10
+ module TokenStore # rubocop:disable Metrics/ModuleLength -- single-responsibility credential store
11
11
  EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
12
12
  KEYCHAIN_SERVICE = 'Claude Code-credentials'
13
13
 
@@ -21,25 +21,44 @@ module RubynCode
21
21
  end
22
22
 
23
23
  # Load API key for a given provider. Anthropic uses the full fallback chain.
24
+ # Other providers: stored key → env var.
24
25
  def load_for_provider(provider)
25
26
  return load if provider == 'anthropic'
26
27
 
28
+ stored = load_provider_key(provider)
29
+ return { access_token: stored, type: :api_key, source: :stored } if stored
30
+
27
31
  env_key = resolve_env_key(provider)
28
32
  api_key = ENV.fetch(env_key, nil)
29
33
  api_key&.empty? == false ? { access_token: api_key, type: :api_key, source: :env } : nil
30
34
  end
31
35
 
32
- def save(access_token:, refresh_token:, expires_at:)
36
+ # Store an API key for a provider in tokens.yml (encrypted at rest).
37
+ def save_provider_key(provider, key)
33
38
  ensure_directory!
39
+ data = load_tokens_file || {}
40
+ data['provider_keys'] ||= {}
41
+ data['provider_keys'][provider.to_s] = KeyEncryption.encrypt(key)
42
+ write_tokens_file(data)
43
+ end
34
44
 
35
- data = {
36
- 'access_token' => access_token,
37
- 'refresh_token' => refresh_token,
38
- 'expires_at' => expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
39
- }
45
+ # Retrieve a stored API key for a provider (decrypted transparently).
46
+ def load_provider_key(provider)
47
+ data = load_tokens_file
48
+ value = data&.dig('provider_keys', provider.to_s)
49
+ return nil unless value
40
50
 
41
- File.write(tokens_path, YAML.dump(data))
42
- File.chmod(0o600, tokens_path)
51
+ migrate_plaintext_key!(data, provider, value) unless KeyEncryption.encrypted?(value)
52
+ KeyEncryption.decrypt(value)
53
+ end
54
+
55
+ def save(access_token:, refresh_token:, expires_at:)
56
+ ensure_directory!
57
+ data = load_tokens_file || {}
58
+ data['access_token'] = access_token
59
+ data['refresh_token'] = refresh_token
60
+ data['expires_at'] = expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
61
+ write_tokens_file(data)
43
62
  data
44
63
  end
45
64
 
@@ -118,6 +137,28 @@ module RubynCode
118
137
  { access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
119
138
  end
120
139
 
140
+ def write_tokens_file(data)
141
+ File.write(tokens_path, YAML.dump(data))
142
+ File.chmod(0o600, tokens_path)
143
+ end
144
+
145
+ # Auto-encrypt a plaintext key from a pre-encryption install.
146
+ def migrate_plaintext_key!(data, provider, plaintext)
147
+ data['provider_keys'][provider.to_s] = KeyEncryption.encrypt(plaintext)
148
+ write_tokens_file(data)
149
+ rescue StandardError
150
+ nil # don't break reads if migration fails
151
+ end
152
+
153
+ def load_tokens_file
154
+ return nil unless File.exist?(tokens_path)
155
+
156
+ data = YAML.safe_load_file(tokens_path, permitted_classes: [Time])
157
+ data.is_a?(Hash) ? data : nil
158
+ rescue Psych::SyntaxError, Errno::EACCES
159
+ nil
160
+ end
161
+
121
162
  def tokens_path = Config::Defaults::TOKENS_FILE
122
163
 
123
164
  def ensure_directory!
@@ -14,8 +14,9 @@ module RubynCode
14
14
  #
15
15
  # Unlike the REPL, the daemon runs a full Agent::Loop per task — meaning
16
16
  # it can read files, write code, run specs, and use every tool available.
17
- class Daemon
17
+ class Daemon # rubocop:disable Metrics/ClassLength -- daemon lifecycle + retry + audit + cost
18
18
  LIFECYCLE_STATES = %i[spawned working idle shutting_down stopped].freeze
19
+ MAX_TASK_RETRIES = 3
19
20
 
20
21
  attr_reader :agent_name, :role, :state, :runs_completed, :total_cost
21
22
 
@@ -32,14 +33,17 @@ module RubynCode
32
33
  # @param on_state_change [Proc, nil] callback invoked with (old_state, new_state)
33
34
  # @param on_task_complete [Proc, nil] callback invoked with (task, result_text)
34
35
  # @param on_task_error [Proc, nil] callback invoked with (task, error)
36
+ # @param session_persistence [Memory::SessionPersistence, nil] optional audit trail persistence
35
37
  def initialize( # rubocop:disable Metrics/ParameterLists
36
38
  agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:,
37
39
  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
40
+ on_state_change: nil, on_task_complete: nil, on_task_error: nil,
41
+ session_persistence: nil
39
42
  )
40
43
  assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:)
41
44
  assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
42
45
  assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
46
+ @session_persistence = session_persistence
43
47
  end
44
48
 
45
49
  # Enters the work-idle-work cycle. Blocks the calling thread until
@@ -153,16 +157,18 @@ module RubynCode
153
157
  agent_loop = build_agent_loop
154
158
  result_text = agent_loop.send_message(build_work_prompt(task))
155
159
 
156
- # Accumulate cost from the budget enforcer
157
- track_cost_from_enforcer(agent_loop)
160
+ # Accumulate cost via CostCalculator using actual token counts
161
+ track_cost_from_context_manager(agent_loop)
158
162
 
159
163
  # Mark the task as completed with the agent's result.
160
164
  @task_manager.complete(task.id, result: result_text)
165
+
166
+ # Persist conversation as an audit trail
167
+ persist_session_audit(task, agent_loop)
168
+
161
169
  @on_task_complete&.call(task, result_text)
162
170
  rescue StandardError => e
163
- # On failure, release the task so another agent (or retry) can pick it up.
164
- @task_manager.update(task.id, status: 'pending', owner: nil, result: "Error: #{e.message}")
165
- @on_task_error&.call(task, e)
171
+ handle_task_error(task, e)
166
172
  end
167
173
 
168
174
  # Builds a fresh Agent::Loop wired with all the real tools.
@@ -192,19 +198,116 @@ module RubynCode
192
198
  )
193
199
  end
194
200
 
195
- # Accumulates cost tracked by the Agent::Loop's context manager.
201
+ # Computes USD cost from the context manager's token counts using
202
+ # Observability::CostCalculator. The old approach checked for a
203
+ # `total_cost` method that never existed on Context::Manager, so
204
+ # @total_cost was always 0.0 and the max_cost safety limit never fired.
196
205
  #
197
206
  # @param agent_loop [Agent::Loop]
198
207
  # @return [void]
199
- def track_cost_from_enforcer(agent_loop)
200
- # The context manager tracks token usage; we extract cost if available.
201
- # This is best-effort — the daemon's own total_cost is an approximation.
208
+ def track_cost_from_context_manager(agent_loop)
202
209
  cm = agent_loop.instance_variable_get(:@context_manager)
203
- return unless cm.respond_to?(:total_cost)
210
+ return unless cm
211
+
212
+ tokens = extract_token_counts(cm)
213
+ return if tokens.values.all?(&:zero?)
214
+
215
+ model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-4-6'
216
+ @total_cost += Observability::CostCalculator.calculate(model: model, **tokens)
217
+ rescue StandardError
218
+ # Non-critical — cost tracking is best-effort
219
+ end
220
+
221
+ # @param context_mgr [Context::Manager]
222
+ # @return [Hash] :input_tokens, :output_tokens
223
+ def extract_token_counts(context_mgr)
224
+ {
225
+ input_tokens: context_mgr.respond_to?(:total_input_tokens) ? context_mgr.total_input_tokens.to_i : 0,
226
+ output_tokens: context_mgr.respond_to?(:total_output_tokens) ? context_mgr.total_output_tokens.to_i : 0
227
+ }
228
+ end
229
+
230
+ # Handles a task error with retry backoff. Increments the retry count
231
+ # in the task's metadata. After MAX_TASK_RETRIES, marks the task as
232
+ # failed instead of releasing it back to pending.
233
+ #
234
+ # @param task [Tasks::Task]
235
+ # @param error [StandardError]
236
+ # @return [void]
237
+ def handle_task_error(task, error)
238
+ retry_count = extract_retry_count(task) + 1
239
+
240
+ metadata = build_retry_metadata(task, retry_count)
241
+ if retry_count >= MAX_TASK_RETRIES
242
+ @task_manager.update(
243
+ task.id,
244
+ status: 'failed',
245
+ owner: nil,
246
+ result: "Failed after #{retry_count} retries. Last error: #{error.message}",
247
+ metadata: JSON.generate(metadata)
248
+ )
249
+ else
250
+ @task_manager.update(
251
+ task.id,
252
+ status: 'pending',
253
+ owner: nil,
254
+ result: "Error (retry #{retry_count}/#{MAX_TASK_RETRIES}): #{error.message}",
255
+ metadata: JSON.generate(metadata)
256
+ )
257
+ end
258
+ @on_task_error&.call(task, error)
259
+ end
204
260
 
205
- @total_cost += cm.total_cost.to_f
261
+ # @param task [Tasks::Task]
262
+ # @return [Integer]
263
+ def extract_retry_count(task)
264
+ meta = parse_task_metadata(task)
265
+ (meta[:retry_count] || meta['retry_count'] || 0).to_i
266
+ end
267
+
268
+ # @param task [Tasks::Task]
269
+ # @param retry_count [Integer]
270
+ # @return [Hash]
271
+ def build_retry_metadata(task, retry_count)
272
+ meta = parse_task_metadata(task)
273
+ meta.merge(retry_count: retry_count)
274
+ end
275
+
276
+ # @param task [Tasks::Task]
277
+ # @return [Hash]
278
+ def parse_task_metadata(task)
279
+ raw = task.metadata
280
+ case raw
281
+ when Hash then raw
282
+ when String then JSON.parse(raw, symbolize_names: true)
283
+ else {}
284
+ end
285
+ rescue JSON::ParserError
286
+ {}
287
+ end
288
+
289
+ # Persists the agent's conversation as a session audit trail after
290
+ # completing a task, so there's a record of what the daemon did.
291
+ #
292
+ # @param task [Tasks::Task]
293
+ # @param agent_loop [Agent::Loop]
294
+ # @return [void]
295
+ def persist_session_audit(task, agent_loop)
296
+ return unless @session_persistence
297
+
298
+ conversation = agent_loop.instance_variable_get(:@conversation)
299
+ return unless conversation.respond_to?(:messages)
300
+
301
+ session_id = "daemon-#{@agent_name}-#{task.id}"
302
+ @session_persistence.save_session(
303
+ session_id: session_id,
304
+ project_path: @project_root,
305
+ messages: conversation.messages,
306
+ title: "Daemon: #{task.title}",
307
+ metadata: { agent_name: @agent_name, task_id: task.id, task_title: task.title }
308
+ )
206
309
  rescue StandardError
207
- # Non-critical
310
+ # Non-critical — audit persistence is best-effort
208
311
  end
209
312
 
210
313
  # ── Idle phase ───────────────────────────────────────────────
@@ -53,26 +53,6 @@ module RubynCode
53
53
  @interrupted = true
54
54
  end
55
55
 
56
- # Re-injects the agent's identity message when the conversation
57
- # context has been compressed (i.e. the messages array is very short).
58
- # This ensures the agent still knows who it is after compaction.
59
- #
60
- # @param messages [Array<Hash>] the current conversation messages
61
- # @param identity [String] the identity/system prompt to re-inject
62
- # @param threshold [Integer] message count below which re-injection triggers (default 3)
63
- # @return [void]
64
- def self.reinject_identity(messages, identity:, threshold: 3)
65
- return if messages.length >= threshold
66
- return if identity.nil? || identity.empty?
67
-
68
- # Only re-inject if the identity is not already present as the
69
- # first user message.
70
- first_user = messages.find { |m| m[:role] == 'user' }
71
- return if first_user && first_user[:content].to_s.include?(identity[0, 100])
72
-
73
- messages.unshift({ role: 'user', content: identity })
74
- end
75
-
76
56
  private
77
57
 
78
58
  # @return [Boolean]
@@ -6,16 +6,19 @@ module RubynCode
6
6
  # Uses optimistic locking to handle race conditions when multiple
7
7
  # agents attempt to claim the same task concurrently.
8
8
  module TaskClaimer
9
- # Finds the first ready (pending, unowned) task, claims it for the
10
- # given agent, and returns the updated Task. Returns nil if no work
11
- # is available.
9
+ MAX_RETRIES = 3
10
+
11
+ # Finds the first ready (pending, unowned) task that hasn't exceeded
12
+ # max retries, claims it for the given agent, and returns the updated
13
+ # Task. Returns nil if no work is available.
12
14
  #
13
15
  # @param task_manager [#db, #update_task, #list_tasks] task persistence layer
14
16
  # @param agent_name [String] unique identifier of the claiming agent
17
+ # @param max_retries [Integer] maximum retry count before skipping a task
15
18
  # @return [Tasks::Task, nil] the claimed task, or nil if none available
16
- def self.call(task_manager:, agent_name:)
19
+ def self.call(task_manager:, agent_name:, max_retries: MAX_RETRIES)
17
20
  db = task_manager.db
18
- claim_next_pending_task(db, agent_name)
21
+ claim_next_pending_task(db, agent_name, max_retries)
19
22
  fetch_claimed_task(db, agent_name)
20
23
  rescue StandardError => e
21
24
  RubynCode.logger.warn("TaskClaimer: failed to claim task: #{e.message}") if RubynCode.respond_to?(:logger)
@@ -25,17 +28,20 @@ module RubynCode
25
28
  class << self
26
29
  private
27
30
 
28
- def claim_next_pending_task(db, agent_name)
29
- db.execute(<<~SQL, [agent_name])
31
+ def claim_next_pending_task(db, agent_name, max_retries)
32
+ db.execute(<<~SQL, [agent_name, max_retries])
30
33
  UPDATE tasks
31
34
  SET owner = ?,
32
35
  status = 'in_progress',
33
36
  updated_at = datetime('now')
34
37
  WHERE id = (
35
- SELECT id FROM tasks
36
- WHERE status = 'pending'
37
- AND (owner IS NULL OR owner = '')
38
- ORDER BY priority DESC, created_at ASC
38
+ SELECT t.id FROM tasks t
39
+ WHERE t.status = 'pending'
40
+ AND (t.owner IS NULL OR t.owner = '')
41
+ AND COALESCE(
42
+ json_extract(t.metadata, '$.retry_count'), 0
43
+ ) < ?
44
+ ORDER BY t.priority DESC, t.created_at ASC
39
45
  LIMIT 1
40
46
  )
41
47
  AND status = 'pending'