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.
- checksums.yaml +4 -4
- data/README.md +263 -21
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +34 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
- data/lib/rubyn_code/agent/llm_caller.rb +11 -1
- data/lib/rubyn_code/agent/loop.rb +14 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
- data/lib/rubyn_code/agent/tool_processor.rb +25 -3
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/token_store.rb +50 -9
- data/lib/rubyn_code/autonomous/daemon.rb +117 -14
- data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
- data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
- data/lib/rubyn_code/cli/app.rb +116 -11
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
- data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +32 -2
- data/lib/rubyn_code/cli/commands/provider.rb +124 -0
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +54 -3
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/repl.rb +15 -0
- data/lib/rubyn_code/cli/repl_commands.rb +3 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +74 -1
- data/lib/rubyn_code/config/defaults.rb +3 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +12 -6
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +18 -2
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/manager.rb +37 -3
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +112 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +69 -2
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
- data/lib/rubyn_code/llm/client.rb +29 -4
- data/lib/rubyn_code/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
- data/lib/rubyn_code/output/diff_renderer.rb +3 -2
- data/lib/rubyn_code/self_test.rb +316 -0
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +76 -0
- data/lib/rubyn_code/skills/document.rb +8 -2
- data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/matcher.rb +89 -0
- data/lib/rubyn_code/skills/pack_context.rb +163 -0
- data/lib/rubyn_code/skills/pack_installer.rb +194 -0
- data/lib/rubyn_code/skills/pack_manager.rb +230 -0
- data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
- data/lib/rubyn_code/skills/registry_client.rb +241 -0
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/base.rb +13 -0
- data/lib/rubyn_code/tools/bash.rb +5 -0
- data/lib/rubyn_code/tools/edit_file.rb +62 -5
- data/lib/rubyn_code/tools/executor.rb +65 -8
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +7 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +9 -7
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +34 -0
- data/skills/rubyn_self_test.md +88 -1
- 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
|
|
133
|
-
@on_tool_result
|
|
134
|
-
@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
|
-
|
|
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 =
|
|
68
|
-
|
|
69
|
-
return unless loaded && index.nodes.any?
|
|
67
|
+
index = resolve_codebase_index
|
|
68
|
+
return unless index&.nodes&.any?
|
|
70
69
|
|
|
71
|
-
parts << "\n## #{index.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
#
|
|
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
|
-
|
|
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'
|