rubyn-code 0.7.0 → 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 +17 -1
- data/lib/rubyn_code/agent/conversation.rb +11 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +5 -1
- data/lib/rubyn_code/agent/loop.rb +57 -3
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
- data/lib/rubyn_code/agent/tool_processor.rb +10 -0
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/cli/commands/context.rb +27 -0
- data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
- data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
- 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/mcp.rb +32 -8
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- 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/mention_expander.rb +19 -0
- data/lib/rubyn_code/cli/repl.rb +31 -1
- data/lib/rubyn_code/cli/repl_commands.rb +1 -1
- data/lib/rubyn_code/cli/repl_setup.rb +8 -6
- data/lib/rubyn_code/config/defaults.rb +2 -1
- data/lib/rubyn_code/config/schema.json +5 -0
- data/lib/rubyn_code/config/settings.rb +4 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +64 -3
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
- 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 +1 -1
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- 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/discovery.rb +93 -0
- data/lib/rubyn_code/memory/session_persistence.rb +1 -1
- data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/teammate.rb +0 -15
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- 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 +4 -1
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +13 -8
- data/skills/rubyn_self_test.md +140 -0
- metadata +10 -7
- 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/adapters/token_caching.rb +0 -54
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7696c779b900fe4dca806aa73d7fcca79309ebe58a242d16e2d5309b43d44ec4
|
|
4
|
+
data.tar.gz: 2d8d4c8905255ce11e98569f86f4c2d788c620ea30c513fd6771b3c58bb98073
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5d2b5f15b21d7e02b2f6c2252248eb6e933337b3f4c491135f5453c1ddf4ce47c48605aaeb6e26180a38324c8effc7a149e528c794ca9391ec8c43febbc2bdb
|
|
7
|
+
data.tar.gz: 15c9aa06c02dd8c105116b403cba0f36cb5a2b91c8690b66b6320a289011cf6f2986a308fdaacad933fbd221646de84b7f774a47709a7c8409fdc5bddca5b671
|
data/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<a href="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml"><img src="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
|
-
Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 4.
|
|
18
|
+
Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 4.8, running on your existing Claude subscription.
|
|
19
19
|
|
|
20
20
|
<img width="1230" height="280" alt="image" src="https://github.com/user-attachments/assets/14e07ce8-def0-4a8f-ac89-46661361a4eb" />
|
|
21
21
|
|
|
@@ -69,6 +69,22 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
|
|
|
69
69
|
- **IDE-ready** — works in the terminal and inside VS Code with full bidirectional communication
|
|
70
70
|
- **Extensible** — connect external tool servers via MCP, add custom skills, or wire up your own providers
|
|
71
71
|
|
|
72
|
+
## Claude Code Feature Parity
|
|
73
|
+
|
|
74
|
+
Phase 4 ships nine parity features with Claude Code:
|
|
75
|
+
|
|
76
|
+
- **Extended thinking** — `/think <budget>` toggles per-session reasoning; the Anthropic adapter emits `thinking: {type: 'adaptive'}` on Claude 4.6+ models (`{type: 'enabled', budget_tokens}` on older ones).
|
|
77
|
+
- **Reasoning effort** — `/effort <low|medium|high|xhigh|max>` sets the request-level reasoning depth; the Anthropic adapter emits `output_config: {effort}` on the wire. GA on Claude 4.6+ (Opus 4.6/4.7/4.8, Sonnet 4.6/5, Fable 5); `xhigh` needs Opus 4.7+ / Sonnet 5 / Fable 5. Model default is `high`.
|
|
78
|
+
- **Task budgets (beta)** — the agent loop's remaining token budget is sent as `output_config: {task_budget: {type: 'tokens', total}}` on supported models (Fable 5, Sonnet 5, Opus 4.7/4.8), advisory pacing only — `max_tokens` stays the enforced per-response cap.
|
|
79
|
+
- **Image / vision input** — `@chart.png` (and `.jpg` / `.jpeg` / `.gif` / `.webp`) becomes a real image content block attached to the user turn; Anthropic and OpenAI each emit their native shape.
|
|
80
|
+
- **TodoWrite live checklist** — `TodoWrite` tool; the checklist refreshes above the spinner on every tool result so you see in-turn progress at a glance.
|
|
81
|
+
- **Custom-command frontmatter** — `argument-hint`, `allowed-tools`, and `model:` keys in `~/.rubyn-code/commands/*.md`; the loop honors the per-prompt tool restriction and model override.
|
|
82
|
+
- **`.mcp.json` auto-discovery** — project-root MCP servers flow into the REPL automatically; `/mcp` shows entries prefixed `[project]` or `[user]`. OAuth for URL transports is still deferred.
|
|
83
|
+
- **`/export` transcript** — `/export <path>` writes the conversation as Markdown (default) or `--jsonl` for tooling.
|
|
84
|
+
- **Fable 5 refusal handling** — a declined request (`stop_reason: "refusal"`) surfaces a clear message instead of an empty turn; `claude-fable-5` / `claude-mythos-5` requests opt into a server-side Opus 4.8 fallback by default, so most refusals are transparently re-served in the same call.
|
|
85
|
+
|
|
86
|
+
See `docs/04-feature-parity/` for per-feature design + tasks docs.
|
|
87
|
+
|
|
72
88
|
## Install
|
|
73
89
|
|
|
74
90
|
Requires **Ruby 4.0.2+**. Install with your latest Ruby, then pin it so it works in every project:
|
|
@@ -14,7 +14,10 @@ module RubynCode
|
|
|
14
14
|
|
|
15
15
|
# Append a user turn to the conversation.
|
|
16
16
|
#
|
|
17
|
-
# @param content [String]
|
|
17
|
+
# @param content [String, Array<Hash>] either a plain string or an
|
|
18
|
+
# array of content blocks (text, image, etc.). Strings get wrapped
|
|
19
|
+
# as `[{ type: 'text', text: content }]` only if there's a planned
|
|
20
|
+
# Array; we keep raw String for backward compatibility with the API.
|
|
18
21
|
# @return [Hash] the appended message
|
|
19
22
|
def add_user_message(content)
|
|
20
23
|
message = { role: 'user', content: content }
|
|
@@ -155,6 +158,13 @@ module RubynCode
|
|
|
155
158
|
reset_derived_state!
|
|
156
159
|
end
|
|
157
160
|
|
|
161
|
+
# Alias for `messages`. Common shorthand for callers that want to
|
|
162
|
+
# treat the conversation as an Array of message hashes.
|
|
163
|
+
def to_a
|
|
164
|
+
@messages
|
|
165
|
+
end
|
|
166
|
+
alias to_ary to_a
|
|
167
|
+
|
|
158
168
|
private
|
|
159
169
|
|
|
160
170
|
# Derived bookkeeping kept in sync with @messages so hot paths stay
|
|
@@ -25,7 +25,7 @@ module RubynCode
|
|
|
25
25
|
def build_llm_opts
|
|
26
26
|
opts = {
|
|
27
27
|
messages: @conversation.to_api_format,
|
|
28
|
-
tools:
|
|
28
|
+
tools: filtered_tool_definitions,
|
|
29
29
|
system: build_system_prompt,
|
|
30
30
|
on_text: @on_text
|
|
31
31
|
}
|
|
@@ -36,6 +36,8 @@ module RubynCode
|
|
|
36
36
|
total: UsageTracker::TASK_BUDGET_TOTAL, remaining: @task_budget_remaining
|
|
37
37
|
}
|
|
38
38
|
end
|
|
39
|
+
budget = @llm_client.respond_to?(:thinking_budget_tokens) ? @llm_client.thinking_budget_tokens.to_i : 0
|
|
40
|
+
opts[:thinking] = { budget_tokens: budget } if budget.positive?
|
|
39
41
|
opts
|
|
40
42
|
end
|
|
41
43
|
|
|
@@ -45,6 +47,8 @@ module RubynCode
|
|
|
45
47
|
# Falls back to nil (use client's default) if routing fails.
|
|
46
48
|
# -- guard clauses for provider/mode checks
|
|
47
49
|
def routed_model
|
|
50
|
+
return @model_override if @model_override
|
|
51
|
+
|
|
48
52
|
return nil if manual_model_mode?
|
|
49
53
|
|
|
50
54
|
last_user = last_user_message_text
|
|
@@ -53,8 +53,11 @@ module RubynCode
|
|
|
53
53
|
# response is produced or the iteration limit is reached.
|
|
54
54
|
#
|
|
55
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.
|
|
56
59
|
# @return [String] the final assistant text response
|
|
57
|
-
def send_message(user_input)
|
|
60
|
+
def send_message(user_input, blocks: nil)
|
|
58
61
|
initialize_session!
|
|
59
62
|
check_user_feedback(user_input)
|
|
60
63
|
drain_background_notifications
|
|
@@ -62,7 +65,7 @@ module RubynCode
|
|
|
62
65
|
@decision_compactor&.detect_topic_switch(user_input)
|
|
63
66
|
@skill_ttl&.tick!
|
|
64
67
|
autoload_triggered_skills(user_input)
|
|
65
|
-
|
|
68
|
+
append_user_message(user_input, blocks)
|
|
66
69
|
reset_system_prompt_cache!
|
|
67
70
|
reset_iteration_state
|
|
68
71
|
|
|
@@ -79,8 +82,38 @@ module RubynCode
|
|
|
79
82
|
max_iterations_warning(iteration)
|
|
80
83
|
end
|
|
81
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
|
|
111
|
+
end
|
|
112
|
+
|
|
82
113
|
private
|
|
83
114
|
|
|
115
|
+
# Overrides the region above. The internal helpers below are private.
|
|
116
|
+
|
|
84
117
|
# Decide whether the loop should run another iteration after `iteration`
|
|
85
118
|
# turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
|
|
86
119
|
# active /goal) is keeping the agent alive we extend up to a hard ceiling
|
|
@@ -119,6 +152,8 @@ module RubynCode
|
|
|
119
152
|
@tool_wrapper = opts[:tool_wrapper]
|
|
120
153
|
@decision_compactor = build_decision_compactor
|
|
121
154
|
@skill_ttl = Skills::TtlManager.new
|
|
155
|
+
@todo_store = opts.fetch(:todo_store, Tools::TodoStore.new)
|
|
156
|
+
@tool_executor.todo_store = @todo_store
|
|
122
157
|
@session_initialized = false
|
|
123
158
|
end
|
|
124
159
|
|
|
@@ -133,6 +168,7 @@ module RubynCode
|
|
|
133
168
|
def initialize_session!
|
|
134
169
|
return if @session_initialized || !@project_root
|
|
135
170
|
|
|
171
|
+
@todo_store&.clear
|
|
136
172
|
@session_initialized = true
|
|
137
173
|
build_project_profile!
|
|
138
174
|
build_codebase_index!
|
|
@@ -168,13 +204,17 @@ module RubynCode
|
|
|
168
204
|
@output_recovery_count = 0
|
|
169
205
|
@task_budget_remaining = nil
|
|
170
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
|
|
171
209
|
end
|
|
172
210
|
|
|
173
211
|
def run_iteration(iteration)
|
|
174
212
|
log_iteration(iteration)
|
|
175
213
|
@context_manager.advance_turn!
|
|
176
214
|
compact_if_needed # ensure context is under threshold before LLM call
|
|
177
|
-
response
|
|
215
|
+
response = call_llm
|
|
216
|
+
return handle_refusal(response) if extract_stop_reason(response) == 'refusal'
|
|
217
|
+
|
|
178
218
|
tool_calls = extract_tool_calls(response)
|
|
179
219
|
log_response(response, tool_calls)
|
|
180
220
|
|
|
@@ -183,6 +223,20 @@ module RubynCode
|
|
|
183
223
|
handle_tool_response(response, tool_calls, iteration)
|
|
184
224
|
end
|
|
185
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
|
+
|
|
186
240
|
def log_iteration(iteration)
|
|
187
241
|
RubynCode::Debug.loop_tick(
|
|
188
242
|
"iteration=#{iteration} messages=#{@conversation.length} " \
|
|
@@ -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))
|
|
@@ -101,6 +101,9 @@ module RubynCode
|
|
|
101
101
|
return unless index&.nodes&.any?
|
|
102
102
|
|
|
103
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.'
|
|
104
107
|
rescue StandardError
|
|
105
108
|
nil
|
|
106
109
|
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -40,6 +40,33 @@ module RubynCode
|
|
|
40
40
|
|
|
41
41
|
# @return [Boolean]
|
|
42
42
|
def plan_mode? = plan_mode
|
|
43
|
+
|
|
44
|
+
# Restrict the tool set for a single agent invocation. The next
|
|
45
|
+
# `send_message` call inside the block sees only the tools in
|
|
46
|
+
# `allowed`; the override is cleared after the call.
|
|
47
|
+
def with_allowed_tools(allowed = nil, &block)
|
|
48
|
+
apply_loop_override(:allowed_tools_override=, allowed, &block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Apply a one-shot model override for the duration of a block.
|
|
52
|
+
def with_optional_model(model_name = nil, &block)
|
|
53
|
+
apply_loop_override(:model_override=, model_name, &block)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Set an override on the agent loop (when one is wired in) for the
|
|
59
|
+
# duration of the block, then clear it. Custom commands without
|
|
60
|
+
# a loop (e.g. test doubles) fall through to a plain yield.
|
|
61
|
+
def apply_loop_override(method, value)
|
|
62
|
+
loop = agent_loop
|
|
63
|
+
return yield unless loop.respond_to?(method)
|
|
64
|
+
|
|
65
|
+
loop.public_send(method, value)
|
|
66
|
+
yield
|
|
67
|
+
ensure
|
|
68
|
+
loop&.public_send(method, nil) if loop.respond_to?(method)
|
|
69
|
+
end
|
|
43
70
|
end
|
|
44
71
|
end
|
|
45
72
|
end
|
|
@@ -14,13 +14,24 @@ module RubynCode
|
|
|
14
14
|
attr_reader :name
|
|
15
15
|
# @return [String] one-line description for /help
|
|
16
16
|
attr_reader :description
|
|
17
|
+
# @return [String, nil] argument hint shown next to the name in /help
|
|
18
|
+
attr_reader :argument_hint
|
|
19
|
+
# @return [Array<String>, nil] tool names the command restricts to
|
|
20
|
+
attr_reader :allowed_tools
|
|
21
|
+
# @return [String, nil] model override for this prompt
|
|
22
|
+
attr_reader :model
|
|
17
23
|
# @return [String, nil] originating file path
|
|
18
24
|
attr_reader :source
|
|
19
25
|
|
|
20
|
-
def initialize(name:, description:, body:,
|
|
26
|
+
def initialize(name:, description:, body:, # rubocop:disable Metrics/ParameterLists -- explicit kwargs document the frontmatter surface
|
|
27
|
+
source: nil,
|
|
28
|
+
argument_hint: nil, allowed_tools: nil, model: nil)
|
|
21
29
|
@name = name
|
|
22
30
|
@description = description
|
|
23
31
|
@template = CommandTemplate.new(body)
|
|
32
|
+
@argument_hint = argument_hint
|
|
33
|
+
@allowed_tools = allowed_tools
|
|
34
|
+
@model = model
|
|
24
35
|
@source = source
|
|
25
36
|
end
|
|
26
37
|
|
|
@@ -29,11 +40,42 @@ module RubynCode
|
|
|
29
40
|
def hidden? = false
|
|
30
41
|
def all_names = [command_name].freeze
|
|
31
42
|
|
|
43
|
+
# Render the help label, including [hint] when frontmatter provides one.
|
|
44
|
+
# The argument_hint is expected to be the placeholder text already wrapped
|
|
45
|
+
# in [ ] (e.g. "[env]") — we don't add another set of brackets.
|
|
46
|
+
def help_label
|
|
47
|
+
hint = @argument_hint.to_s.strip
|
|
48
|
+
hint.empty? ? description : "#{description} #{hint}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] true if the frontmatter restricted the tool set
|
|
52
|
+
def restricts_tools?
|
|
53
|
+
!@allowed_tools.nil? && !@allowed_tools.empty?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Boolean] true if the frontmatter overrode the model
|
|
57
|
+
def overrides_model?
|
|
58
|
+
!@model.to_s.strip.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
32
61
|
# @param args [Array<String>]
|
|
33
62
|
# @param ctx [Commands::Context]
|
|
34
63
|
# @return [nil]
|
|
35
64
|
def execute(args, ctx)
|
|
36
|
-
|
|
65
|
+
rendered = @template.render(args)
|
|
66
|
+
if restricts_tools?
|
|
67
|
+
ctx.with_allowed_tools(@allowed_tools) do
|
|
68
|
+
ctx.with_optional_model(@model) do
|
|
69
|
+
ctx.send_message(rendered)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
elsif overrides_model?
|
|
73
|
+
ctx.with_optional_model(@model) do
|
|
74
|
+
ctx.send_message(rendered)
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
ctx.send_message(rendered)
|
|
78
|
+
end
|
|
37
79
|
nil
|
|
38
80
|
end
|
|
39
81
|
end
|
|
@@ -40,9 +40,18 @@ module RubynCode
|
|
|
40
40
|
name = File.basename(path, '.md').strip
|
|
41
41
|
return nil unless name.match?(NAME)
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
description =
|
|
45
|
-
|
|
43
|
+
meta, body = parse(File.read(path))
|
|
44
|
+
description = meta['description'].to_s
|
|
45
|
+
description = "Custom command: /#{name}" if description.strip.empty?
|
|
46
|
+
CustomCommand.new(
|
|
47
|
+
name: name,
|
|
48
|
+
description: description,
|
|
49
|
+
body: body,
|
|
50
|
+
source: path,
|
|
51
|
+
argument_hint: blank_to_nil(stringify(meta['argument_hint'] || meta['argument-hint'])),
|
|
52
|
+
allowed_tools: list_of_tools(meta['allowed_tools'] || meta['allowed-tools']),
|
|
53
|
+
model: blank_to_nil(stringify(meta['model']))
|
|
54
|
+
)
|
|
46
55
|
rescue StandardError => e
|
|
47
56
|
RubynCode::Debug.warn("Failed to load custom command #{path}: #{e.message}")
|
|
48
57
|
nil
|
|
@@ -51,13 +60,35 @@ module RubynCode
|
|
|
51
60
|
def parse(content)
|
|
52
61
|
if (match = FRONTMATTER.match(content))
|
|
53
62
|
frontmatter = YAML.safe_load(match[1]) || {}
|
|
54
|
-
[frontmatter
|
|
63
|
+
[frontmatter, match[2].to_s.strip]
|
|
55
64
|
else
|
|
56
65
|
body = content.to_s.strip
|
|
57
|
-
[first_line(body), body]
|
|
66
|
+
[{ 'description' => first_line(body) }, body]
|
|
58
67
|
end
|
|
59
68
|
end
|
|
60
69
|
|
|
70
|
+
def blank_to_nil(value)
|
|
71
|
+
value.to_s.strip.empty? ? nil : value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stringify(value)
|
|
75
|
+
return '' if value.nil?
|
|
76
|
+
|
|
77
|
+
# YAML's flow-style `[env]` is parsed as a single-element array.
|
|
78
|
+
# Stringify the array element directly so users see exactly the
|
|
79
|
+
# characters they typed.
|
|
80
|
+
str = value.is_a?(Array) ? value.join : value
|
|
81
|
+
str.to_s.strip
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def list_of_tools(value)
|
|
85
|
+
return nil if value.nil?
|
|
86
|
+
|
|
87
|
+
arr = value.is_a?(Array) ? value : value.to_s.split(',')
|
|
88
|
+
cleaned = arr.map { |name| name.to_s.strip }.reject(&:empty?)
|
|
89
|
+
cleaned.empty? ? nil : cleaned
|
|
90
|
+
end
|
|
91
|
+
|
|
61
92
|
def first_line(body)
|
|
62
93
|
line = body.lines.first.to_s.strip
|
|
63
94
|
line.sub(/\A#+\s*/, '')[0, 80]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Set or clear the reasoning effort level (output_config.effort).
|
|
7
|
+
#
|
|
8
|
+
# /effort # show current value
|
|
9
|
+
# /effort off # clear back to model default
|
|
10
|
+
# /effort <level> # low | medium | high | xhigh | max
|
|
11
|
+
class Effort < Base
|
|
12
|
+
LEVELS = %w[low medium high xhigh max].freeze
|
|
13
|
+
|
|
14
|
+
def self.command_name = '/effort'
|
|
15
|
+
def self.description = 'Set reasoning effort (low/medium/high/xhigh/max)'
|
|
16
|
+
|
|
17
|
+
def execute(args, ctx)
|
|
18
|
+
arg = args.first
|
|
19
|
+
|
|
20
|
+
current = ctx.llm_client.respond_to?(:effort) ? ctx.llm_client.effort : nil
|
|
21
|
+
|
|
22
|
+
case arg
|
|
23
|
+
when nil
|
|
24
|
+
show_status(current, ctx)
|
|
25
|
+
when 'off'
|
|
26
|
+
ctx.llm_client.effort = nil
|
|
27
|
+
ctx.renderer.info('Reasoning effort cleared — using model default 🎚️')
|
|
28
|
+
when *LEVELS
|
|
29
|
+
ctx.llm_client.effort = arg
|
|
30
|
+
ctx.renderer.info("Reasoning effort set to #{arg} 🎚️")
|
|
31
|
+
else
|
|
32
|
+
ctx.renderer.warning("Usage: /effort [off|#{LEVELS.join('|')}]. Got: #{arg}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def show_status(current, ctx)
|
|
39
|
+
state = current || 'not set (model default: high on 4.6+)'
|
|
40
|
+
ctx.renderer.info("Reasoning effort: #{state}")
|
|
41
|
+
ctx.renderer.info("Usage: /effort <level> levels: #{LEVELS.join(', ')} (or /effort off)")
|
|
42
|
+
ctx.renderer.info('Supported on Claude 4.6+ models; xhigh requires Opus 4.7+ / Sonnet 5 / Fable 5.')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Export the current conversation transcript to a file.
|
|
10
|
+
#
|
|
11
|
+
# /export path/to/transcript.md # markdown (default)
|
|
12
|
+
# /export --format jsonl path/to/transcript.jsonl
|
|
13
|
+
# /export path/to/transcript.md --force # overwrite existing
|
|
14
|
+
class Export < Base
|
|
15
|
+
def self.command_name = '/export'
|
|
16
|
+
def self.description = 'Export conversation transcript to markdown or JSONL'
|
|
17
|
+
|
|
18
|
+
MAX_LINE_LENGTH = 100
|
|
19
|
+
|
|
20
|
+
# -- CLI arg parsing is intrinsically procedural
|
|
21
|
+
def execute(args, ctx)
|
|
22
|
+
opts = parse_args(args)
|
|
23
|
+
return unless opts
|
|
24
|
+
|
|
25
|
+
messages = ctx.conversation.to_a
|
|
26
|
+
if messages.empty?
|
|
27
|
+
ctx.renderer.warning('No messages to export.')
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
path = File.expand_path(opts[:path])
|
|
32
|
+
dir = File.dirname(path)
|
|
33
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
34
|
+
|
|
35
|
+
if File.exist?(path) && !opts[:force] && !tty_yes?(ctx, "File exists at #{path}. Overwrite? [y/N]")
|
|
36
|
+
ctx.renderer.info('Export cancelled.')
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
body = opts[:format] == 'jsonl' ? render_jsonl(messages) : render_markdown(messages)
|
|
41
|
+
File.write(path, body)
|
|
42
|
+
plural = messages.size == 1 ? '' : 's'
|
|
43
|
+
ctx.renderer.info("Exported #{messages.size} message#{plural} to #{path}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# @return [Hash{Symbol=>Object}, nil] nil on bad input
|
|
49
|
+
# -- arg parsing is intrinsically branchy
|
|
50
|
+
def parse_args(args) # rubocop:disable Metrics/MethodLength -- arg parsing is intrinsically procedural
|
|
51
|
+
format = 'markdown'
|
|
52
|
+
force = false
|
|
53
|
+
path = nil
|
|
54
|
+
|
|
55
|
+
if (idx = args.index('--format') || args.index('-f'))
|
|
56
|
+
format = args[idx + 1].to_s
|
|
57
|
+
end
|
|
58
|
+
format = case format
|
|
59
|
+
when 'md' then 'markdown'
|
|
60
|
+
else format
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
args.each do |a|
|
|
64
|
+
case a
|
|
65
|
+
when '--jsonl'
|
|
66
|
+
format = 'jsonl'
|
|
67
|
+
when '--markdown', '--md'
|
|
68
|
+
format = 'markdown'
|
|
69
|
+
when '--format', '-f'
|
|
70
|
+
# already handled above
|
|
71
|
+
when '--force', '-y'
|
|
72
|
+
force = true
|
|
73
|
+
when /\A--.+/
|
|
74
|
+
next
|
|
75
|
+
else
|
|
76
|
+
path ||= a
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
return nil if path.to_s.empty?
|
|
81
|
+
|
|
82
|
+
{ path: path, format: format, force: force }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_markdown(messages)
|
|
86
|
+
title = 'Rubyn transcript'
|
|
87
|
+
buffer = String.new
|
|
88
|
+
buffer << "# #{title}\n\n"
|
|
89
|
+
buffer << "_Exported at #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n"
|
|
90
|
+
messages.each { |msg| render_markdown_message(buffer, msg) }
|
|
91
|
+
buffer
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def render_markdown_message(buffer, msg)
|
|
95
|
+
role = msg[:role] || msg['role']
|
|
96
|
+
case role
|
|
97
|
+
when 'user'
|
|
98
|
+
text = render_user_text(msg[:content] || msg['content'])
|
|
99
|
+
buffer << "## User\n\n#{text}\n\n"
|
|
100
|
+
when 'assistant'
|
|
101
|
+
render_assistant_blocks(buffer, msg[:content] || msg['content'])
|
|
102
|
+
when 'tool'
|
|
103
|
+
buffer << "### tool result\n\n```\n#{msg[:content] || msg['content']}\n```\n\n"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def render_user_text(content)
|
|
108
|
+
blocks = normalize_content(content)
|
|
109
|
+
if blocks.any? { |b| block_type(b) == 'image' }
|
|
110
|
+
blocks.map { |b| block_type(b) == 'image' ? '_(image attachment)_' : text_of(b) }.join("\n")
|
|
111
|
+
else
|
|
112
|
+
blocks.map { |b| text_of(b) }.join("\n")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_assistant_blocks(buffer, content)
|
|
117
|
+
blocks = normalize_content(content)
|
|
118
|
+
if blocks.empty?
|
|
119
|
+
buffer << "## Assistant\n\n_(empty response)_\n\n"
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
blocks.each do |b|
|
|
123
|
+
render_assistant_block(buffer, b)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def render_assistant_block(buffer, b) # rubocop:disable Naming/MethodParameterName -- short names are clearer here than "block"
|
|
128
|
+
case block_type(b)
|
|
129
|
+
when 'text'
|
|
130
|
+
buffer << "## Assistant\n\n#{text_of(b)}\n\n"
|
|
131
|
+
when 'thinking'
|
|
132
|
+
buffer << "<details><summary>thinking</summary>\n\n#{text_of(b)}\n\n</details>\n\n"
|
|
133
|
+
when 'tool_use'
|
|
134
|
+
buffer << "[tool: #{b[:name] || b['name']}]\n\n```json\n#{JSON.pretty_generate(input_of(b))}\n```\n\n"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def render_jsonl(messages)
|
|
139
|
+
lines = messages.map do |msg|
|
|
140
|
+
role = msg[:role] || msg['role']
|
|
141
|
+
raw = msg[:content] || msg['content']
|
|
142
|
+
content = raw.is_a?(String) ? raw : normalize_content(raw)
|
|
143
|
+
"#{JSON.generate(role: role, content: content, timestamp: Time.now.utc.iso8601)}\n"
|
|
144
|
+
end
|
|
145
|
+
lines.join
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Wrap a String content into a single text block so we can render uniformly.
|
|
149
|
+
def normalize_content(content)
|
|
150
|
+
return [{ type: 'text', text: content.to_s }] if content.is_a?(String)
|
|
151
|
+
return [{ type: 'text', text: '' }] if content.nil?
|
|
152
|
+
|
|
153
|
+
content
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def block_type(block)
|
|
157
|
+
block[:type] || block['type']
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def text_of(block)
|
|
161
|
+
(block[:text] || block['text']).to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def input_of(block)
|
|
165
|
+
(block[:input] || block['input']) || {}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def tty_yes?(ctx, prompt)
|
|
169
|
+
ctx.renderer.respond_to?(:ask) ? ctx.renderer.ask(prompt, default: false) : true
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|