rubyn-code 0.5.1 → 0.8.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 +137 -4
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +100 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +7 -3
- data/lib/rubyn_code/agent/loop.rb +106 -12
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
- data/lib/rubyn_code/agent/tool_processor.rb +13 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +30 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/commands/think.rb +47 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +111 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +68 -15
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +17 -7
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +14 -1
- data/lib/rubyn_code/config/schema.json +9 -0
- data/lib/rubyn_code/config/settings.rb +19 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +103 -4
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/discovery.rb +93 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +3 -14
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +57 -9
- data/skills/rubyn_self_test.md +462 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +45 -6
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -9,7 +9,7 @@ require_relative 'llm_caller'
|
|
|
9
9
|
|
|
10
10
|
module RubynCode
|
|
11
11
|
module Agent
|
|
12
|
-
class Loop
|
|
12
|
+
class Loop # rubocop:disable Metrics/ClassLength -- core agent loop: LLM calls, tool dispatch, recovery, hooks
|
|
13
13
|
include SystemPromptBuilder
|
|
14
14
|
include ResponseParser
|
|
15
15
|
include ToolProcessor
|
|
@@ -18,6 +18,7 @@ module RubynCode
|
|
|
18
18
|
include LlmCaller
|
|
19
19
|
|
|
20
20
|
MAX_ITERATIONS = Config::Defaults::MAX_ITERATIONS
|
|
21
|
+
GOAL_MAX_ITERATIONS = Config::Defaults::GOAL_MAX_ITERATIONS
|
|
21
22
|
|
|
22
23
|
# @param opts [Hash] keyword arguments for loop configuration
|
|
23
24
|
# @option opts [LLM::Client] :llm_client
|
|
@@ -39,6 +40,7 @@ module RubynCode
|
|
|
39
40
|
assign_dependencies(opts)
|
|
40
41
|
assign_callbacks(opts)
|
|
41
42
|
@plan_mode = false
|
|
43
|
+
@static_prompt_sections = nil
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# @return [Boolean]
|
|
@@ -51,8 +53,11 @@ module RubynCode
|
|
|
51
53
|
# response is produced or the iteration limit is reached.
|
|
52
54
|
#
|
|
53
55
|
# @param user_input [String]
|
|
56
|
+
# @param blocks [Array<Hash>, nil] extra content blocks to attach to the
|
|
57
|
+
# user message (e.g. image blocks from @-mentions). When non-empty the
|
|
58
|
+
# user message is stored as a mixed content array instead of a string.
|
|
54
59
|
# @return [String] the final assistant text response
|
|
55
|
-
def send_message(user_input)
|
|
60
|
+
def send_message(user_input, blocks: nil)
|
|
56
61
|
initialize_session!
|
|
57
62
|
check_user_feedback(user_input)
|
|
58
63
|
drain_background_notifications
|
|
@@ -60,20 +65,67 @@ module RubynCode
|
|
|
60
65
|
@decision_compactor&.detect_topic_switch(user_input)
|
|
61
66
|
@skill_ttl&.tick!
|
|
62
67
|
autoload_triggered_skills(user_input)
|
|
63
|
-
|
|
68
|
+
append_user_message(user_input, blocks)
|
|
69
|
+
reset_system_prompt_cache!
|
|
64
70
|
reset_iteration_state
|
|
65
71
|
|
|
66
|
-
|
|
72
|
+
iteration = 0
|
|
73
|
+
loop do
|
|
67
74
|
result = run_iteration(iteration)
|
|
68
75
|
return result if result
|
|
76
|
+
|
|
77
|
+
iteration += 1
|
|
78
|
+
break unless keep_iterating?(iteration)
|
|
69
79
|
end
|
|
70
80
|
|
|
71
|
-
RubynCode::Debug.warn("Hit
|
|
72
|
-
max_iterations_warning
|
|
81
|
+
RubynCode::Debug.warn("Hit iteration limit (#{iteration})")
|
|
82
|
+
max_iterations_warning(iteration)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Append the user turn to the conversation. If image blocks were passed
|
|
86
|
+
# via `blocks:`, the message is stored as a mixed content array with a
|
|
87
|
+
# leading text block and the image blocks appended in order.
|
|
88
|
+
def append_user_message(user_input, blocks)
|
|
89
|
+
if blocks.is_a?(Array) && !blocks.empty?
|
|
90
|
+
content = [{ type: 'text', text: user_input.to_s }, *blocks]
|
|
91
|
+
@conversation.add_user_message(content)
|
|
92
|
+
else
|
|
93
|
+
@conversation.add_user_message(user_input)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Tools::TodoStore] shared checklist store, exposed for the REPL renderer
|
|
98
|
+
attr_reader :todo_store
|
|
99
|
+
|
|
100
|
+
# Apply a one-shot model override for the duration of the next LLM call.
|
|
101
|
+
# Used by `Context#with_optional_model` for custom-command frontmatter.
|
|
102
|
+
def model_override=(model_name)
|
|
103
|
+
stripped = model_name.to_s.strip
|
|
104
|
+
@model_override = stripped.empty? ? nil : stripped
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Restrict the available tool set for the next LLM call only.
|
|
108
|
+
# @param allowed [Array<String>, nil] tool names, or nil to clear the override.
|
|
109
|
+
def allowed_tools_override=(allowed)
|
|
110
|
+
@allowed_tools_override = allowed.is_a?(Array) && !allowed.empty? ? allowed : nil
|
|
73
111
|
end
|
|
74
112
|
|
|
75
113
|
private
|
|
76
114
|
|
|
115
|
+
# Overrides the region above. The internal helpers below are private.
|
|
116
|
+
|
|
117
|
+
# Decide whether the loop should run another iteration after `iteration`
|
|
118
|
+
# turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
|
|
119
|
+
# active /goal) is keeping the agent alive we extend up to a hard ceiling
|
|
120
|
+
# — a goal can need more tool turns than a single request. The GoalHook's
|
|
121
|
+
# own max-attempts valve terminates an unsatisfiable goal; the ceiling is
|
|
122
|
+
# only a runaway guard.
|
|
123
|
+
def keep_iterating?(iteration)
|
|
124
|
+
return true if iteration < MAX_ITERATIONS
|
|
125
|
+
|
|
126
|
+
@stop_block_active && iteration < GOAL_MAX_ITERATIONS
|
|
127
|
+
end
|
|
128
|
+
|
|
77
129
|
def assign_dependencies(opts)
|
|
78
130
|
assign_required_deps(opts)
|
|
79
131
|
assign_optional_deps(opts)
|
|
@@ -100,6 +152,8 @@ module RubynCode
|
|
|
100
152
|
@tool_wrapper = opts[:tool_wrapper]
|
|
101
153
|
@decision_compactor = build_decision_compactor
|
|
102
154
|
@skill_ttl = Skills::TtlManager.new
|
|
155
|
+
@todo_store = opts.fetch(:todo_store, Tools::TodoStore.new)
|
|
156
|
+
@tool_executor.todo_store = @todo_store
|
|
103
157
|
@session_initialized = false
|
|
104
158
|
end
|
|
105
159
|
|
|
@@ -114,6 +168,7 @@ module RubynCode
|
|
|
114
168
|
def initialize_session!
|
|
115
169
|
return if @session_initialized || !@project_root
|
|
116
170
|
|
|
171
|
+
@todo_store&.clear
|
|
117
172
|
@session_initialized = true
|
|
118
173
|
build_project_profile!
|
|
119
174
|
build_codebase_index!
|
|
@@ -148,13 +203,18 @@ module RubynCode
|
|
|
148
203
|
@max_tokens_override = nil
|
|
149
204
|
@output_recovery_count = 0
|
|
150
205
|
@task_budget_remaining = nil
|
|
206
|
+
@stop_block_active = false # true while a Stop hook keeps us going
|
|
207
|
+
@allowed_tools_override = nil # set by Context#with_allowed_tools
|
|
208
|
+
@model_override = nil # set by Context#with_optional_model
|
|
151
209
|
end
|
|
152
210
|
|
|
153
211
|
def run_iteration(iteration)
|
|
154
212
|
log_iteration(iteration)
|
|
155
213
|
@context_manager.advance_turn!
|
|
156
214
|
compact_if_needed # ensure context is under threshold before LLM call
|
|
157
|
-
response
|
|
215
|
+
response = call_llm
|
|
216
|
+
return handle_refusal(response) if extract_stop_reason(response) == 'refusal'
|
|
217
|
+
|
|
158
218
|
tool_calls = extract_tool_calls(response)
|
|
159
219
|
log_response(response, tool_calls)
|
|
160
220
|
|
|
@@ -163,6 +223,20 @@ module RubynCode
|
|
|
163
223
|
handle_tool_response(response, tool_calls, iteration)
|
|
164
224
|
end
|
|
165
225
|
|
|
226
|
+
# Claude's safety classifiers declined the request outright (HTTP 200,
|
|
227
|
+
# stop_reason: "refusal"). Surface a clear message instead of falling
|
|
228
|
+
# through to text/empty-response handling, which would misread the
|
|
229
|
+
# empty or partial content as "waiting on background jobs".
|
|
230
|
+
def handle_refusal(response)
|
|
231
|
+
details = extract_stop_details(response)
|
|
232
|
+
category = details.is_a?(Hash) ? (details['category'] || details[:category]) : nil
|
|
233
|
+
RubynCode::Debug.llm("Refusal: category=#{category || 'unknown'}")
|
|
234
|
+
|
|
235
|
+
message = "Claude's safety system declined this request (category: #{category || 'unknown'})."
|
|
236
|
+
@conversation.add_assistant_message([{ type: 'text', text: message }])
|
|
237
|
+
message
|
|
238
|
+
end
|
|
239
|
+
|
|
166
240
|
def log_iteration(iteration)
|
|
167
241
|
RubynCode::Debug.loop_tick(
|
|
168
242
|
"iteration=#{iteration} messages=#{@conversation.length} " \
|
|
@@ -199,6 +273,13 @@ module RubynCode
|
|
|
199
273
|
|
|
200
274
|
@conversation.add_assistant_message(response_content(response))
|
|
201
275
|
|
|
276
|
+
# Stop hook: a hook may block stopping (e.g. an active /goal). When
|
|
277
|
+
# blocked, the reason is injected as user feedback and the loop keeps
|
|
278
|
+
# iterating instead of returning the final text. While blocked, the
|
|
279
|
+
# loop is allowed to run past MAX_ITERATIONS (see #keep_iterating?).
|
|
280
|
+
@stop_block_active = stop_blocked?(text)
|
|
281
|
+
return nil if @stop_block_active
|
|
282
|
+
|
|
202
283
|
# Decision-based compaction (topic switch, milestone)
|
|
203
284
|
@decision_compactor&.check!(@conversation)
|
|
204
285
|
|
|
@@ -208,6 +289,19 @@ module RubynCode
|
|
|
208
289
|
text
|
|
209
290
|
end
|
|
210
291
|
|
|
292
|
+
# Fires the :stop hook. If a hook blocks (returns { block: true }), the
|
|
293
|
+
# reason is appended as a user message so the next iteration acts on it.
|
|
294
|
+
#
|
|
295
|
+
# @return [Boolean] true if stopping was blocked (keep iterating)
|
|
296
|
+
def stop_blocked?(text)
|
|
297
|
+
decision = @hook_runner.fire(:stop, conversation: @conversation, response_text: text)
|
|
298
|
+
return false unless decision.is_a?(Hash) && decision[:block]
|
|
299
|
+
|
|
300
|
+
RubynCode::Debug.agent('Stop blocked by hook — continuing')
|
|
301
|
+
@conversation.add_user_message(decision[:reason])
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
|
|
211
305
|
# Empty LLM response (0 content blocks). Common after dispatching
|
|
212
306
|
# background_run — the LLM has nothing to say until results arrive.
|
|
213
307
|
# Wait briefly for jobs, then either continue or accept the empty response.
|
|
@@ -243,15 +337,15 @@ module RubynCode
|
|
|
243
337
|
# after text responses — mirrors Claude Code's "pause for compaction"
|
|
244
338
|
# behavior that keeps context manageable in long sessions.
|
|
245
339
|
def compact_if_needed
|
|
246
|
-
return unless @context_manager.needs_compaction?(@conversation
|
|
340
|
+
return unless @context_manager.needs_compaction?(@conversation)
|
|
247
341
|
|
|
248
|
-
est = @context_manager.estimated_tokens(@conversation
|
|
342
|
+
est = @context_manager.estimated_tokens(@conversation)
|
|
249
343
|
RubynCode::Debug.token(
|
|
250
344
|
"Context over threshold (#{est}) — running compaction"
|
|
251
345
|
)
|
|
252
346
|
@context_manager.check_compaction!(@conversation)
|
|
253
347
|
|
|
254
|
-
after = @context_manager.estimated_tokens(@conversation
|
|
348
|
+
after = @context_manager.estimated_tokens(@conversation)
|
|
255
349
|
RubynCode::Debug.token("Compacted: #{est} → #{after} tokens")
|
|
256
350
|
rescue StandardError => e
|
|
257
351
|
RubynCode::Debug.warn("Compaction failed: #{e.message}")
|
|
@@ -266,8 +360,8 @@ module RubynCode
|
|
|
266
360
|
@max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
|
|
267
361
|
end
|
|
268
362
|
|
|
269
|
-
def max_iterations_warning
|
|
270
|
-
warning = "Reached maximum iteration limit (#{
|
|
363
|
+
def max_iterations_warning(limit = MAX_ITERATIONS)
|
|
364
|
+
warning = "Reached maximum iteration limit (#{limit}). " \
|
|
271
365
|
'The conversation may be incomplete. Please review the ' \
|
|
272
366
|
'current state and continue if needed.'
|
|
273
367
|
@conversation.add_assistant_message([{ type: 'text', text: warning }])
|
|
@@ -61,6 +61,14 @@ module RubynCode
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
def extract_stop_details(response)
|
|
65
|
+
if response.respond_to?(:stop_details)
|
|
66
|
+
response.stop_details
|
|
67
|
+
elsif response.is_a?(Hash)
|
|
68
|
+
response[:stop_details] || response['stop_details']
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
64
72
|
def recover_truncated_response(response)
|
|
65
73
|
@max_tokens_override ||= Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
|
|
66
74
|
@conversation.add_assistant_message(response_content(response))
|
|
@@ -10,7 +10,7 @@ module RubynCode
|
|
|
10
10
|
module SystemPromptBuilder # rubocop:disable Metrics/ModuleLength -- heavily extracted, residual 3 lines over
|
|
11
11
|
include Prompts
|
|
12
12
|
|
|
13
|
-
INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENT.md].freeze
|
|
13
|
+
INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENTS.md AGENT.md].freeze
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
@@ -19,16 +19,49 @@ module RubynCode
|
|
|
19
19
|
parts << PLAN_MODE_PROMPT if @plan_mode
|
|
20
20
|
parts << "Working directory: #{@project_root}" if @project_root
|
|
21
21
|
append_response_mode(parts)
|
|
22
|
+
static = static_prompt_sections
|
|
23
|
+
parts << static unless static.empty?
|
|
24
|
+
parts.join("\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The static sections hit SQLite (memories, instincts) and walk the
|
|
28
|
+
# filesystem (instruction files, profile), so they're assembled once
|
|
29
|
+
# per user turn instead of on every iteration of the tool loop. Only
|
|
30
|
+
# the plan-mode flag and response-mode line vary mid-turn, and those
|
|
31
|
+
# stay in build_system_prompt.
|
|
32
|
+
def static_prompt_sections
|
|
33
|
+
@static_prompt_sections ||= build_static_prompt_sections
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_static_prompt_sections
|
|
37
|
+
parts = []
|
|
22
38
|
append_project_profile(parts)
|
|
23
39
|
append_codebase_index(parts)
|
|
24
40
|
append_memories(parts)
|
|
25
41
|
append_project_instructions(parts)
|
|
26
42
|
append_instincts(parts)
|
|
27
43
|
append_skills(parts)
|
|
44
|
+
append_chisel_ruleset(parts)
|
|
28
45
|
append_deferred_tools(parts)
|
|
29
46
|
parts.join("\n")
|
|
30
47
|
end
|
|
31
48
|
|
|
49
|
+
# Chisel's "write the minimum that works" ruleset, injected only when the
|
|
50
|
+
# user has turned it on (chisel_mode != off). Guarded so a config or
|
|
51
|
+
# resolution error never breaks prompt assembly.
|
|
52
|
+
def append_chisel_ruleset(parts)
|
|
53
|
+
section = Chisel.prompt_section
|
|
54
|
+
parts << "\n#{section}" unless section.empty?
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Called at the start of each user turn so memory, instruction, and
|
|
60
|
+
# tool changes made between turns show up in the next prompt.
|
|
61
|
+
def reset_system_prompt_cache!
|
|
62
|
+
@static_prompt_sections = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
32
65
|
def append_response_mode(parts)
|
|
33
66
|
text = last_user_text
|
|
34
67
|
return if text.empty?
|
|
@@ -68,6 +101,9 @@ module RubynCode
|
|
|
68
101
|
return unless index&.nodes&.any?
|
|
69
102
|
|
|
70
103
|
parts << "\n## #{index.to_structural_summary}"
|
|
104
|
+
parts << 'This project has a code graph. To locate or understand code, call `code_graph` FIRST — ' \
|
|
105
|
+
'it returns matching symbols with source, callers, and callees in one call. ' \
|
|
106
|
+
'Fall back to grep/glob only when code_graph finds nothing.'
|
|
71
107
|
rescue StandardError
|
|
72
108
|
nil
|
|
73
109
|
end
|
|
@@ -182,7 +218,9 @@ module RubynCode
|
|
|
182
218
|
|
|
183
219
|
db = DB::Connection.instance
|
|
184
220
|
search = Memory::Search.new(db, project_path: @project_root)
|
|
185
|
-
|
|
221
|
+
# touch: false — assembling the prompt is not a memory "access";
|
|
222
|
+
# touching here would issue a SQLite write and inflate access counts.
|
|
223
|
+
recent = search.recent(limit: 20, touch: false)
|
|
186
224
|
return '' if recent.empty?
|
|
187
225
|
|
|
188
226
|
recent.map { |m| format_memory(m) }.join("\n")
|
|
@@ -52,6 +52,16 @@ module RubynCode
|
|
|
52
52
|
Tools::Registry.all.select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }.map(&:to_schema)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Tool definitions for the next LLM call: plan mode narrows to
|
|
56
|
+
# read-only tools; a per-turn allowed-tools override (custom-command
|
|
57
|
+
# frontmatter, set via Loop#allowed_tools_override=) narrows by name.
|
|
58
|
+
def filtered_tool_definitions
|
|
59
|
+
defs = @plan_mode ? read_only_tool_definitions : tool_definitions
|
|
60
|
+
return defs unless @allowed_tools_override
|
|
61
|
+
|
|
62
|
+
DynamicToolSchema.filter(defs, active_names: @allowed_tools_override)
|
|
63
|
+
end
|
|
64
|
+
|
|
55
65
|
# -- tool dispatch with budget + signals
|
|
56
66
|
def process_tool_calls(tool_calls)
|
|
57
67
|
aggregate_chars = 0
|
|
@@ -116,7 +126,9 @@ module RubynCode
|
|
|
116
126
|
|
|
117
127
|
def execute_tool(tool_name, tool_input)
|
|
118
128
|
discover_tool(tool_name)
|
|
119
|
-
@hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
|
|
129
|
+
pre_decision = @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
|
|
130
|
+
raise RubynCode::UserDeniedError, pre_decision[:reason] if pre_decision.is_a?(Hash) && pre_decision[:deny]
|
|
131
|
+
|
|
120
132
|
result = dispatch_tool(tool_name, tool_input)
|
|
121
133
|
@hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
|
|
122
134
|
signal_decision_compactor(tool_name, tool_input, result)
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require 'securerandom'
|
|
4
4
|
require 'digest'
|
|
5
5
|
require 'base64'
|
|
6
|
-
require 'faraday'
|
|
7
6
|
require 'json'
|
|
8
7
|
|
|
9
8
|
module RubynCode
|
|
@@ -163,6 +162,7 @@ module RubynCode
|
|
|
163
162
|
end
|
|
164
163
|
|
|
165
164
|
def http_client
|
|
165
|
+
require 'faraday'
|
|
166
166
|
@http_client ||= Faraday.new do |f|
|
|
167
167
|
f.options.timeout = 30
|
|
168
168
|
f.options.open_timeout = 10
|
|
@@ -11,13 +11,27 @@ module RubynCode
|
|
|
11
11
|
EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
|
|
12
12
|
KEYCHAIN_SERVICE = 'Claude Code-credentials'
|
|
13
13
|
|
|
14
|
+
# Strategy chain: each method returns a token hash or nil.
|
|
15
|
+
# First non-nil result wins. Adding a new auth source is a one-line entry.
|
|
16
|
+
LOAD_STRATEGIES = %i[
|
|
17
|
+
load_from_keychain
|
|
18
|
+
load_from_credentials_file
|
|
19
|
+
load_from_file
|
|
20
|
+
load_from_env
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
14
23
|
class << self
|
|
15
24
|
# Load tokens with fallback chain:
|
|
16
25
|
# 1. macOS Keychain (Claude Code's OAuth token)
|
|
17
|
-
# 2.
|
|
18
|
-
# 3.
|
|
26
|
+
# 2. Claude Code credentials file (~/.claude/.credentials.json)
|
|
27
|
+
# 3. Local YAML file (~/.rubyn-code/tokens.yml)
|
|
28
|
+
# 4. ANTHROPIC_API_KEY environment variable
|
|
19
29
|
def load
|
|
20
|
-
|
|
30
|
+
LOAD_STRATEGIES.each do |strategy|
|
|
31
|
+
result = send(strategy)
|
|
32
|
+
return result if result
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
21
35
|
end
|
|
22
36
|
|
|
23
37
|
# Load API key for a given provider. Anthropic uses the full fallback chain.
|
|
@@ -68,7 +82,12 @@ module RubynCode
|
|
|
68
82
|
end
|
|
69
83
|
|
|
70
84
|
def valid?
|
|
71
|
-
|
|
85
|
+
valid_tokens?(self.load)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Validate an already-loaded token hash without re-reading the
|
|
89
|
+
# keychain — lets callers cache the result of `load`.
|
|
90
|
+
def valid_tokens?(tokens)
|
|
72
91
|
return false unless tokens&.fetch(:access_token, nil)
|
|
73
92
|
return true if tokens[:type] == :api_key
|
|
74
93
|
return true unless tokens[:expires_at]
|
|
@@ -88,6 +107,7 @@ module RubynCode
|
|
|
88
107
|
default
|
|
89
108
|
end
|
|
90
109
|
|
|
110
|
+
# macOS only: read from Keychain Services
|
|
91
111
|
def load_from_keychain
|
|
92
112
|
return nil unless RUBY_PLATFORM.include?('darwin')
|
|
93
113
|
|
|
@@ -102,6 +122,21 @@ module RubynCode
|
|
|
102
122
|
nil
|
|
103
123
|
end
|
|
104
124
|
|
|
125
|
+
# Linux/other: Claude Code stores OAuth in a plain JSON file
|
|
126
|
+
def load_from_credentials_file
|
|
127
|
+
path = Config::Defaults::CLAUDE_CREDENTIALS_FILE
|
|
128
|
+
return nil unless File.exist?(path)
|
|
129
|
+
|
|
130
|
+
warn_insecure_permissions(path)
|
|
131
|
+
|
|
132
|
+
oauth = JSON.parse(File.read(path))['claudeAiOauth']
|
|
133
|
+
return nil unless oauth&.dig('accessToken')
|
|
134
|
+
|
|
135
|
+
build_keychain_tokens(oauth)
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
105
140
|
def build_keychain_tokens(oauth)
|
|
106
141
|
{
|
|
107
142
|
access_token: oauth['accessToken'],
|
|
@@ -137,6 +172,16 @@ module RubynCode
|
|
|
137
172
|
{ access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
|
|
138
173
|
end
|
|
139
174
|
|
|
175
|
+
# Warn when credentials file has loose permissions (no system ACLs on Linux)
|
|
176
|
+
def warn_insecure_permissions(path)
|
|
177
|
+
mode = File.stat(path).mode & 0o777
|
|
178
|
+
return if mode == 0o600
|
|
179
|
+
|
|
180
|
+
warn "[rubyn-code] WARNING: #{path} has mode #{format('%04o', mode)}, expected 0600"
|
|
181
|
+
rescue StandardError
|
|
182
|
+
nil # best-effort — don't fail a token load over a stat
|
|
183
|
+
end
|
|
184
|
+
|
|
140
185
|
def write_tokens_file(data)
|
|
141
186
|
File.write(tokens_path, YAML.dump(data))
|
|
142
187
|
File.chmod(0o600, tokens_path)
|
|
@@ -212,7 +212,7 @@ module RubynCode
|
|
|
212
212
|
tokens = extract_token_counts(cm)
|
|
213
213
|
return if tokens.values.all?(&:zero?)
|
|
214
214
|
|
|
215
|
-
model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-
|
|
215
|
+
model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-5'
|
|
216
216
|
@total_cost += Observability::CostCalculator.calculate(model: model, **tokens)
|
|
217
217
|
rescue StandardError
|
|
218
218
|
# Non-critical — cost tracking is best-effort
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Checkpoint
|
|
5
|
+
# A :pre_tool_use hook that snapshots a file's original contents into the
|
|
6
|
+
# current checkpoint just before a mutating tool changes it. Only the
|
|
7
|
+
# file-mutating tools are watched; everything else is ignored.
|
|
8
|
+
class Hook
|
|
9
|
+
MUTATING_TOOLS = %w[write_file edit_file].freeze
|
|
10
|
+
|
|
11
|
+
# @param manager [Checkpoint::Manager]
|
|
12
|
+
def initialize(manager:)
|
|
13
|
+
@manager = manager
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [nil] never blocks the tool (returns no deny decision)
|
|
17
|
+
def call(tool_name:, tool_input: {}, **_kwargs)
|
|
18
|
+
return nil unless MUTATING_TOOLS.include?(tool_name.to_s)
|
|
19
|
+
|
|
20
|
+
path = tool_input[:path] || tool_input['path']
|
|
21
|
+
@manager.record_file(path) if path
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
# Checkpoints let a user rewind a session, mirroring Claude Code's /rewind.
|
|
7
|
+
# A checkpoint is taken at the start of each user turn and captures:
|
|
8
|
+
# - the conversation state (so chat can be rolled back), and
|
|
9
|
+
# - the original contents of every file mutated during that turn (so code
|
|
10
|
+
# can be restored).
|
|
11
|
+
#
|
|
12
|
+
# File contents are captured lazily by Checkpoint::Hook on :pre_tool_use,
|
|
13
|
+
# just before a mutating tool runs, so only files that actually change are
|
|
14
|
+
# snapshotted.
|
|
15
|
+
module Checkpoint
|
|
16
|
+
# Marker stored for a path that did not exist when first touched, so a
|
|
17
|
+
# rewind deletes it rather than recreating empty content.
|
|
18
|
+
ABSENT = :absent
|
|
19
|
+
|
|
20
|
+
class Manager
|
|
21
|
+
MAX_CHECKPOINTS = 30
|
|
22
|
+
|
|
23
|
+
def initialize(project_root:)
|
|
24
|
+
@project_root = project_root
|
|
25
|
+
@checkpoints = []
|
|
26
|
+
@seq = 0
|
|
27
|
+
@current = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Open a new checkpoint for a user turn. Captures the conversation as it
|
|
31
|
+
# stands before the agent acts.
|
|
32
|
+
#
|
|
33
|
+
# @param label [String] short description (usually the user's message)
|
|
34
|
+
# @param conversation [Agent::Conversation]
|
|
35
|
+
# @return [Integer] the checkpoint id
|
|
36
|
+
def checkpoint!(label:, conversation:)
|
|
37
|
+
@seq += 1
|
|
38
|
+
@current = {
|
|
39
|
+
id: @seq,
|
|
40
|
+
label: summarize(label),
|
|
41
|
+
messages: Array(conversation.messages).dup,
|
|
42
|
+
files: {}
|
|
43
|
+
}
|
|
44
|
+
@checkpoints << @current
|
|
45
|
+
@checkpoints.shift while @checkpoints.size > MAX_CHECKPOINTS
|
|
46
|
+
@seq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Record a file's original contents before it is mutated (once per
|
|
50
|
+
# checkpoint per path). No-op when no checkpoint is open.
|
|
51
|
+
#
|
|
52
|
+
# @param path [String] absolute or project-relative path
|
|
53
|
+
# @return [void]
|
|
54
|
+
def record_file(path)
|
|
55
|
+
return unless @current && path
|
|
56
|
+
|
|
57
|
+
abs = File.expand_path(path.to_s, @project_root)
|
|
58
|
+
return if @current[:files].key?(abs)
|
|
59
|
+
|
|
60
|
+
@current[:files][abs] = File.file?(abs) ? File.read(abs) : ABSENT
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
RubynCode::Debug.warn("Checkpoint capture failed for #{path}: #{e.message}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Array<Hash>] {id:, label:, files:} newest last
|
|
66
|
+
def list
|
|
67
|
+
@checkpoints.map { |c| { id: c[:id], label: c[:label], files: c[:files].size } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def empty? = @checkpoints.empty?
|
|
71
|
+
|
|
72
|
+
def latest_id = @checkpoints.last&.fetch(:id)
|
|
73
|
+
|
|
74
|
+
# Restore a checkpoint. Scope :both (default), :code, or :chat.
|
|
75
|
+
# Checkpoints newer than the restored one are discarded.
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash, nil] summary { id:, files_restored: } or nil if not found
|
|
78
|
+
def restore(id, conversation, scope: :both)
|
|
79
|
+
checkpoint = @checkpoints.find { |c| c[:id] == id }
|
|
80
|
+
return nil unless checkpoint
|
|
81
|
+
|
|
82
|
+
restored_files = restore_files(checkpoint) if %i[both code].include?(scope)
|
|
83
|
+
conversation.replace!(checkpoint[:messages].dup) if %i[both chat].include?(scope)
|
|
84
|
+
|
|
85
|
+
@checkpoints.reject! { |c| c[:id] > id }
|
|
86
|
+
@current = nil
|
|
87
|
+
{ id: id, files_restored: restored_files || 0 }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def restore_files(checkpoint)
|
|
93
|
+
checkpoint[:files].each do |abs, content|
|
|
94
|
+
if content == ABSENT
|
|
95
|
+
FileUtils.rm_f(abs)
|
|
96
|
+
else
|
|
97
|
+
File.write(abs, content)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
checkpoint[:files].size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def summarize(label)
|
|
104
|
+
text = label.to_s.tr("\n", ' ').strip
|
|
105
|
+
text.length > 60 ? "#{text[0, 57]}…" : text
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Chisel
|
|
5
|
+
# Harvests inline deferral markers from the codebase. A marker is a code
|
|
6
|
+
# comment whose text begins with the lowercase tag "chisel:" and records a
|
|
7
|
+
# simplification you consciously postponed (e.g. a comment reading
|
|
8
|
+
# "chisel: collapse this adapter once a second caller exists").
|
|
9
|
+
#
|
|
10
|
+
# Finding them is a deterministic grep — stdlib does it, so there's no LLM
|
|
11
|
+
# round-trip here (Chisel's own ladder, applied to Chisel).
|
|
12
|
+
module Debt
|
|
13
|
+
Item = Data.define(:file, :line, :note)
|
|
14
|
+
|
|
15
|
+
# The whole line must be a comment whose first token is the lowercase tag:
|
|
16
|
+
# optional indentation, a `#` or `//` leader, then `chisel:`, then the note.
|
|
17
|
+
# Anchoring to line-start means a `chisel:` substring inside a string
|
|
18
|
+
# literal or a trailing code comment is NOT harvested — only a marker on its
|
|
19
|
+
# own comment line is. Case-sensitive on purpose, so a descriptive comment
|
|
20
|
+
# that merely starts with "Chisel:" is not a marker.
|
|
21
|
+
MARKER = %r{\A\s*(?:#|//)\s*chisel:\s*(\S.*)}
|
|
22
|
+
|
|
23
|
+
SCAN_EXTENSIONS = %w[.rb .rake .erb .ru .gemspec].freeze
|
|
24
|
+
SKIP_DIRS = %w[.git node_modules vendor coverage tmp log].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param root [String, nil] project root to scan
|
|
29
|
+
# @return [Array<Item>] markers found, in file/line order
|
|
30
|
+
def scan(root)
|
|
31
|
+
return [] unless root
|
|
32
|
+
|
|
33
|
+
base = File.expand_path(root)
|
|
34
|
+
return [] unless Dir.exist?(base)
|
|
35
|
+
|
|
36
|
+
source_files(base).flat_map { |path| scan_file(base, path) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param base [String] expanded project root (no trailing slash)
|
|
40
|
+
# @return [Array<String>] absolute paths of scannable source files
|
|
41
|
+
def source_files(base)
|
|
42
|
+
pattern = File.join(base, '**', "*{#{SCAN_EXTENSIONS.join(',')}}")
|
|
43
|
+
Dir.glob(pattern).reject { |path| skip?(base, path) }.sort
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def skip?(base, path)
|
|
47
|
+
rel = path.delete_prefix("#{base}/")
|
|
48
|
+
SKIP_DIRS.any? { |dir| rel == dir || rel.start_with?("#{dir}/") || rel.include?("/#{dir}/") }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Array<Item>] markers in a single file ([] if it can't be read)
|
|
52
|
+
def scan_file(base, path)
|
|
53
|
+
rel = path.delete_prefix("#{base}/")
|
|
54
|
+
items = []
|
|
55
|
+
File.foreach(path).with_index(1) do |line, number|
|
|
56
|
+
match = MARKER.match(line)
|
|
57
|
+
items << Item.new(file: rel, line: number, note: match[1].strip) if match
|
|
58
|
+
end
|
|
59
|
+
items
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|